mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 12:30:58 +00:00
Merge pull request #440 from Tria-plc/freight_feature/usermanagement
enhance booking windows section with pagination and improved UI
This commit is contained in:
@@ -431,7 +431,7 @@ export class BookingPricingService {
|
||||
|
||||
const lines: PriceLineItemDto[] = [];
|
||||
const usedRatesMap = new Map<string, Rate>();
|
||||
const wagonCount = await this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
const wagonCount = await this.resolveWagonCount(booking);
|
||||
|
||||
for (const container of evalInput.containers) {
|
||||
const rate = this.pickRate(liveRates, rateType, container.containerTypeId, 'USD');
|
||||
@@ -571,6 +571,23 @@ export class BookingPricingService {
|
||||
return { lineItems: lines, usedRates: [...usedRatesMap.values()] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wagon count for PER_WAGON rates. A persisted booking uses the SQL aggregate;
|
||||
* an unsaved preview booking (no id) sums the wagonsRequired already computed
|
||||
* on its in-memory container lines — same math, no DB row needed.
|
||||
*/
|
||||
private async resolveWagonCount(booking: Booking): Promise<number> {
|
||||
if (!booking.id) {
|
||||
return Math.ceil(
|
||||
(booking.bookingContainers ?? []).reduce(
|
||||
(sum, bc) => sum + Number(bc.wagonsRequired ?? 0),
|
||||
0,
|
||||
),
|
||||
);
|
||||
}
|
||||
return this.bookingsRepository.calculateWagonCount(booking.id);
|
||||
}
|
||||
|
||||
/** Friendly container-type label for the per-unit card; degrades to "Container". */
|
||||
private async containerTypeLabel(containerTypeId: string): Promise<string> {
|
||||
try {
|
||||
|
||||
@@ -1051,7 +1051,22 @@ export class BookingTransitionService {
|
||||
// paid → auto-allocated by the settle/paid pipeline. Consolidated bookings
|
||||
// only reserve once both partners are FULLY_EXECUTED (handled inside).
|
||||
const fresh = await this.bookingsService.findById(booking.id);
|
||||
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||
try {
|
||||
await this.bookingBatchService.acceptExportBooking(fresh);
|
||||
} catch (err) {
|
||||
// The status update above already committed. Without compensation the
|
||||
// client gets an error for a booking that reads as accepted after a
|
||||
// refresh — half-applied state. Put the request back so staff can retry.
|
||||
await this.bookingsRepository.update(booking.id, {
|
||||
status: "OPERATION_REQUEST_PENDING",
|
||||
fullyExecutedAt: null,
|
||||
lockedAt: booking.lockedAt ?? null,
|
||||
} as never);
|
||||
this.logger.warn(
|
||||
`Export accept failed post-commit for ${booking.reference}:${booking.id}; reverted to OPERATION_REQUEST_PENDING: ${(err as Error).message}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
// IMPORT and DOMESTIC bookings wait for their booking-day window cycle — the
|
||||
// batch runs after the window closes + staff document review, never at accept
|
||||
@@ -1073,23 +1088,59 @@ export class BookingTransitionService {
|
||||
} | null;
|
||||
}
|
||||
> {
|
||||
const note = await this.bookingsRepository.findLatestReviewNote(
|
||||
booking.id,
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
const summary =
|
||||
booking.contractSummary ??
|
||||
this.contractService.buildContractSummary(booking);
|
||||
const nextPending =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||
: null;
|
||||
const nextStep = computeNextStep(booking, nextPending);
|
||||
const activeBatchOffer =
|
||||
booking.status === "SELECTED_FOR_BATCH"
|
||||
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
|
||||
: null;
|
||||
// This enrichment runs AFTER the transition has committed. A failure here
|
||||
// must never 500 the response — the client would report "failed" for a
|
||||
// transition that actually succeeded (visible only after a refresh).
|
||||
// Degrade each fragile field to null instead.
|
||||
let note: Awaited<
|
||||
ReturnType<typeof this.bookingsRepository.findLatestReviewNote>
|
||||
> = null;
|
||||
try {
|
||||
note = await this.bookingsRepository.findLatestReviewNote(
|
||||
booking.id,
|
||||
"CHANGES_REQUESTED",
|
||||
);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: review-note lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
let summary: string | null = booking.contractSummary ?? null;
|
||||
try {
|
||||
summary =
|
||||
booking.contractSummary ??
|
||||
this.contractService.buildContractSummary(booking);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: contract summary failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
let nextStep: BookingNextStep | null = null;
|
||||
try {
|
||||
const nextPending =
|
||||
booking.status === "PENDING_APPROVAL" ||
|
||||
booking.status === "APPROVED_PENDING_SIGNATURE"
|
||||
? await this.bookingsRepository.findNextPendingApprovalStep(booking.id)
|
||||
: null;
|
||||
nextStep = computeNextStep(booking, nextPending);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: next-step lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
let activeBatchOffer: Awaited<
|
||||
ReturnType<typeof this.bookingBatchService.getOpenOfferSummary>
|
||||
> = null;
|
||||
try {
|
||||
activeBatchOffer =
|
||||
booking.status === "SELECTED_FOR_BATCH"
|
||||
? await this.bookingBatchService.getOpenOfferSummary(booking.id)
|
||||
: null;
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
...booking,
|
||||
latestChangeRequestNote: note?.note ?? null,
|
||||
|
||||
@@ -587,6 +587,10 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
.leftJoinAndSelect('booking.serviceType', 'serviceType')
|
||||
.leftJoinAndSelect('booking.approvalSteps', 'approvalSteps')
|
||||
.leftJoinAndSelect('booking.consolidationPartner', 'consolidationPartner')
|
||||
// Contract reference for the list column + search (no entity relation on
|
||||
// Booking → contract, so join by id and select just the reference).
|
||||
.leftJoin('freight.contracts', 'contract', 'contract.id = booking.contract_id')
|
||||
.addSelect('contract.reference', 'contract_reference')
|
||||
.where('booking.deleted_at IS NULL');
|
||||
|
||||
this.applyListFilters(qb, options);
|
||||
@@ -605,10 +609,24 @@ export class BookingsRepository extends BaseRepository<Booking> {
|
||||
qb.orderBy(sortField, options.sortOrder ?? 'DESC');
|
||||
}
|
||||
|
||||
const [items, total] = await qb
|
||||
const total = await qb.getCount();
|
||||
const { entities: items, raw } = await qb
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
.getRawAndEntities();
|
||||
|
||||
// The joined contract.reference comes back on the raw rows only (entity has no
|
||||
// contract relation) — map it onto each booking by position.
|
||||
const contractRefByBooking = new Map<string, string | null>();
|
||||
for (const row of raw as Array<{ booking_id: string; contract_reference: string | null }>) {
|
||||
if (row.booking_id && !contractRefByBooking.has(row.booking_id)) {
|
||||
contractRefByBooking.set(row.booking_id, row.contract_reference ?? null);
|
||||
}
|
||||
}
|
||||
for (const item of items) {
|
||||
(item as Booking & { contractReference?: string | null }).contractReference =
|
||||
contractRefByBooking.get(item.id) ?? null;
|
||||
}
|
||||
|
||||
if (items.length) {
|
||||
const links = await this.dataSource.getRepository(TrainScheduleBooking).find({
|
||||
|
||||
@@ -8,13 +8,13 @@ import {
|
||||
forwardRef,
|
||||
} from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
import { ExchangeService } from '@edr/api-common';
|
||||
|
||||
import { Booking } from '../bookings/entities/booking.entity';
|
||||
import { BookingContainer } from '../bookings/entities/booking-container.entity';
|
||||
import { BookingContainerUnit } from '../bookings/entities/booking-container-unit.entity';
|
||||
import { BookingsRepository } from '../bookings/bookings.repository';
|
||||
import { BookingPricingService } from '../bookings/booking-pricing.service';
|
||||
import { PriceLineItemDto } from '../bookings/dto/generate-price-response.dto';
|
||||
import { BookingInvoiceService } from '../bookings/booking-invoice.service';
|
||||
import { validate20ftWeightPairing } from '../bookings/container-pairing.util';
|
||||
import { TrainSchedulingGlobalRules } from '../train-scheduling/entities/train-scheduling-global-rules.entity';
|
||||
@@ -66,7 +66,6 @@ export class ContractBookingService {
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly dataSource: DataSource,
|
||||
private readonly exchangeService: ExchangeService,
|
||||
@Inject(forwardRef(() => TrainSchedulingService))
|
||||
private readonly trainSchedulingService: TrainSchedulingService,
|
||||
) {}
|
||||
@@ -587,11 +586,14 @@ export class ContractBookingService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-create validation for the shipment form: run the overweight rule + the
|
||||
* 20ft weight-pairing rule against the entered containers WITHOUT persisting a
|
||||
* booking. The portal calls this from the price-confirm modal so the customer
|
||||
* sees the overweight warning (+ surcharge basis) and is blocked on an
|
||||
* un-pairable 20ft set before the booking is created.
|
||||
* Pre-create validation + authoritative price preview for the shipment form:
|
||||
* build an UNSAVED booking shaped exactly like {@link createUnderContract}
|
||||
* would persist it and run the same BookingPricingService compute over it —
|
||||
* base rail freight, first/last-mile trucking, and every rule-engine surcharge
|
||||
* (overweight, hazard, reefer, consolidation, …). The portal and the GL
|
||||
* backoffice form call this from the price-confirm modal, so the breakdown the
|
||||
* user confirms is line-for-line what the booking will be charged. Also runs
|
||||
* the 20ft weight-pairing rule, which hard-blocks creation.
|
||||
*/
|
||||
async validateShipment(
|
||||
contractId: string,
|
||||
@@ -606,22 +608,26 @@ export class ContractBookingService {
|
||||
overweightSurchargeAmount: number;
|
||||
currency: string | null;
|
||||
pairingErrors: string[];
|
||||
lineItems: PriceLineItemDto[];
|
||||
totalAmount: number;
|
||||
}> {
|
||||
const contract = await this.contractsRepository.findByIdWithRelations(contractId);
|
||||
if (!contract) throw new NotFoundException(`Contract ${contractId} not found`);
|
||||
|
||||
const lines = dto.containers ?? [];
|
||||
if (!lines.length) {
|
||||
if (contract.freightType === 'CONTAINER' && !lines.length) {
|
||||
return {
|
||||
overweightLines: [],
|
||||
overweightSurchargeAmount: 0,
|
||||
currency: null,
|
||||
pairingErrors: [],
|
||||
lineItems: [],
|
||||
totalAmount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// Resolve each line's container type + total VGM (sum of unit weights) so the
|
||||
// rule engine can flag overweight per line (maxVgmTons × quantity vs total).
|
||||
// Resolve each container line's type + total VGM (sum of unit weights) —
|
||||
// mirrors persistContainers so the preview lines match the persisted ones.
|
||||
const resolved = await Promise.all(
|
||||
lines.map(async (line) => {
|
||||
const ct = await this.resolveContainerTypeForSize(
|
||||
@@ -636,46 +642,44 @@ export class ContractBookingService {
|
||||
}),
|
||||
);
|
||||
|
||||
const ruleResult = await this.ruleEngineService.evaluate({
|
||||
freightType: 'CONTAINER',
|
||||
cargoTypeId: null,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
// The unsaved twin of the booking createUnderContract would write: same
|
||||
// denormalized contract fields, same container-line math. No id → the
|
||||
// pricing service derives wagon counts from the in-memory lines.
|
||||
const route = await this.resolveRoute(contract, dto.contractRouteId);
|
||||
const previewBooking = Object.assign(new Booking(), {
|
||||
freightType: contract.freightType,
|
||||
tradeDirection: contract.tradeDirection,
|
||||
isHazardous: false,
|
||||
isReefer: contract.isReefer ?? false,
|
||||
isGovernment: false,
|
||||
allowConsolidation: false,
|
||||
paymentCurrency: contract.paymentCurrency,
|
||||
serviceTypeId: contract.serviceTypeId,
|
||||
cargoTypeId: this.resolveCargoTypeId(contract, dto),
|
||||
isHazardous: contract.isHazardous,
|
||||
isReefer: contract.isReefer,
|
||||
isGovernment: contract.isGovernment,
|
||||
shippingLineId: null,
|
||||
totalWagons: 0,
|
||||
bulkTons: 0,
|
||||
containers: resolved.map((r) => ({
|
||||
containerTypeId: r.ct.id,
|
||||
quantity: r.line.quantity,
|
||||
vgmPerUnitTons: r.line.quantity ? r.totalVgmTons / r.line.quantity : 0,
|
||||
totalVgmTons: r.totalVgmTons,
|
||||
isReefer: r.ct.isReefer,
|
||||
})),
|
||||
} as never);
|
||||
contractRouteId: route?.id ?? null,
|
||||
cargoTotalWeightVgm: this.resolveBulkTons(dto),
|
||||
firstMilePickupAddress: contract.firstMilePickupAddress ?? null,
|
||||
lastMileDeliveryAddress: contract.lastMileDeliveryAddress ?? null,
|
||||
bookingContainers: resolved.map(({ line, ct, totalVgmTons }) =>
|
||||
Object.assign(new BookingContainer(), {
|
||||
containerTypeId: ct.id,
|
||||
containerSize: line.containerSize,
|
||||
quantity: line.quantity,
|
||||
hazardousQuantity: line.hazardousQuantity ?? 0,
|
||||
reeferQuantity: line.reeferQuantity ?? 0,
|
||||
vgmPerUnitTons: line.units.length ? totalVgmTons / line.units.length : 0,
|
||||
totalVgmTons,
|
||||
wagonsRequired: Math.ceil(line.quantity * Number(ct.wagonsPerUnit ?? 1)),
|
||||
}),
|
||||
),
|
||||
}) as Booking;
|
||||
|
||||
const overweightLines: Array<{
|
||||
containerTypeCode: string;
|
||||
totalVgmTons: number;
|
||||
maxAllowedTons: number;
|
||||
excessTons: number;
|
||||
}> = [];
|
||||
for (let i = 0; i < ruleResult.containerWeightResults.length; i++) {
|
||||
const wr = ruleResult.containerWeightResults[i];
|
||||
if (!wr?.isOverweight) continue;
|
||||
const r = resolved[i];
|
||||
const excessTons = Number(wr.overweightExcessTons ?? 0);
|
||||
overweightLines.push({
|
||||
containerTypeCode: r?.ct.code ?? r?.line.containerSize ?? '',
|
||||
totalVgmTons: r?.totalVgmTons ?? 0,
|
||||
maxAllowedTons: Math.max(0, (r?.totalVgmTons ?? 0) - excessTons),
|
||||
excessTons,
|
||||
});
|
||||
}
|
||||
const computed = await this.bookingPricingService.computePriceForBooking(previewBooking);
|
||||
|
||||
// The overweight surcharge line is already currency-converted; surface its
|
||||
// amount separately so the warning alert can reference the exact charge.
|
||||
const overweightSurchargeAmount =
|
||||
computed.lineItems.find((li) => li.code === 'OVERWEIGHT_PER_TON')?.amount ?? 0;
|
||||
|
||||
// 20ft weight-pairing: gather every 20ft unit weight and check the pair rule.
|
||||
const twentyFtUnits = resolved
|
||||
@@ -691,27 +695,13 @@ export class ContractBookingService {
|
||||
(v) => v.message,
|
||||
);
|
||||
|
||||
// Real overweight surcharge (same rate the rule engine bills at booking-create
|
||||
// time) so the confirm-modal total isn't missing the charge the warning refers to.
|
||||
// Rates are stored in USD; convert to the contract's payment currency the same
|
||||
// way BookingPricingService does so this preview matches the eventual booking total.
|
||||
const overweightModifier = ruleResult.appliedModifiers.find(
|
||||
(m) => m.surchargeCode === 'OVERWEIGHT_PER_TON',
|
||||
);
|
||||
let overweightSurchargeAmount = 0;
|
||||
if (overweightModifier) {
|
||||
const isEtb = contract.paymentCurrency === 'ETB';
|
||||
const usdToEtb = isEtb ? await this.exchangeService.getRate('USD', 'ETB') : 1;
|
||||
overweightSurchargeAmount = isEtb
|
||||
? Math.round(overweightModifier.calculatedAmount * usdToEtb)
|
||||
: overweightModifier.calculatedAmount;
|
||||
}
|
||||
|
||||
return {
|
||||
overweightLines,
|
||||
overweightLines: computed.overweightLines,
|
||||
overweightSurchargeAmount,
|
||||
currency: overweightLines.length ? contract.paymentCurrency : null,
|
||||
currency: computed.currency,
|
||||
pairingErrors,
|
||||
lineItems: computed.lineItems,
|
||||
totalAmount: computed.totalAmount,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -791,7 +791,7 @@ export class ContractsController {
|
||||
@Post(':id/validate-shipment')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Pre-create validation: overweight lines + 20ft weight-pairing errors for a shipment payload (no booking created).',
|
||||
'Pre-create validation + authoritative price preview: full booking price breakdown (rail, first/last mile, surcharges), overweight lines and 20ft weight-pairing errors for a shipment payload (no booking created).',
|
||||
})
|
||||
validateShipment(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -83,6 +83,16 @@ export class TrainSchedulingController {
|
||||
return this.trainSchedulingService.getBookingWindowsForContract(contractId);
|
||||
}
|
||||
|
||||
@Get("booking-windows")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"All announced booking windows across lanes (import cycle + export FCFS), for staff dashboards",
|
||||
})
|
||||
listBookingWindows() {
|
||||
return this.trainSchedulingService.listAllBookingWindows();
|
||||
}
|
||||
|
||||
@Get("global-rules")
|
||||
@TrainSchedulingView()
|
||||
@ApiOperation({ summary: "Get global train scheduling rules (singleton)" })
|
||||
|
||||
@@ -3220,6 +3220,50 @@ export class TrainSchedulingService {
|
||||
return rows.map((r) => this.mapBookingWindowRow(r));
|
||||
}
|
||||
|
||||
/**
|
||||
* All announced booking windows across every lane — import window cycles AND
|
||||
* export FCFS lead windows — for staff dashboards (GL clearance queue). Same
|
||||
* phase filter as the customer-facing lists, no contract scoping.
|
||||
*/
|
||||
async listAllBookingWindows() {
|
||||
const rows: Array<
|
||||
Omit<BookingWindowRow, 'contract_id' | 'contract_kind'> & {
|
||||
train_number: string | null;
|
||||
}
|
||||
> = await this.dataSource.query(
|
||||
`SELECT ts.id AS schedule_id,
|
||||
ts.train_number,
|
||||
ts.direction,
|
||||
ts.window_phase,
|
||||
ts.window_opens_at,
|
||||
ts.window_closes_at,
|
||||
ts.doc_review_ends_at,
|
||||
ts.payment_phase_ends_at,
|
||||
ts.booking_window_status,
|
||||
ts.booking_cycle_no,
|
||||
ts.scheduled_departure_date,
|
||||
oy.label AS origin_label, oy.code AS origin_code,
|
||||
dy.label AS destination_label, dy.code AS destination_code
|
||||
FROM freight.train_schedules ts
|
||||
LEFT JOIN freight.yards oy ON oy.id = ts.origin_station_id
|
||||
LEFT JOIN freight.yards dy ON dy.id = ts.destination_station_id
|
||||
WHERE ts.deleted_at IS NULL
|
||||
AND ts.status IN ('DRAFT', 'SCHEDULED')
|
||||
AND ts.window_phase IS NOT NULL
|
||||
AND ts.window_phase NOT IN ('DONE', 'CLOSED_FOR_DAY')
|
||||
AND ts.scheduled_departure_date >= now()
|
||||
ORDER BY ts.window_opens_at ASC NULLS LAST`,
|
||||
);
|
||||
return rows.map((r) => ({
|
||||
...this.mapBookingWindowRow({
|
||||
...r,
|
||||
contract_id: null,
|
||||
contract_kind: null,
|
||||
}),
|
||||
trainNumber: r.train_number,
|
||||
}));
|
||||
}
|
||||
|
||||
private mapBookingWindowRow(r: BookingWindowRow) {
|
||||
return {
|
||||
scheduleId: r.schedule_id,
|
||||
|
||||
Reference in New Issue
Block a user