mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 01:13:26 +00:00
Merge branch 'dev' into freight/nati-2
This commit is contained in:
@@ -20,6 +20,10 @@ import { FirstMileService } from "../first-mile/first-mile.service";
|
||||
import { BookingBatchService } from "../train-scheduling/booking-batch.service";
|
||||
import { PriceLineItemDto } from "./dto/generate-price-response.dto";
|
||||
import { BookingsRepository } from "./bookings.repository";
|
||||
import {
|
||||
BookingWagonCancellationService,
|
||||
WAGON_CANCEL_FEE_INVOICE_TYPE,
|
||||
} from "./booking-wagon-cancellation.service";
|
||||
import { Booking } from "./entities/booking.entity";
|
||||
|
||||
/** Snapshot written onto `booking.pricingBreakdown` by the pricing service. */
|
||||
@@ -58,6 +62,8 @@ export class BookingInvoiceService {
|
||||
private readonly firstMile: FirstMileService,
|
||||
@Inject(forwardRef(() => BookingBatchService))
|
||||
private readonly bookingBatch: BookingBatchService,
|
||||
@Inject(forwardRef(() => BookingWagonCancellationService))
|
||||
private readonly wagonCancellations: BookingWagonCancellationService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
@@ -90,6 +96,21 @@ export class BookingInvoiceService {
|
||||
return this.billing.generateInvoice(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel the booking's open PREPAID invoice, if any — used when a
|
||||
* changes-requested resubmit restates the cargo, so the re-priced booking can
|
||||
* be re-invoiced. Throws when the invoice already has payments recorded
|
||||
* (cargo must not change out from under recorded money).
|
||||
*/
|
||||
async cancelUnpaidInvoiceForBooking(bookingId: string): Promise<void> {
|
||||
const existing = await this.billing.findPayable(
|
||||
Freight.InvoiceSource.Booking,
|
||||
bookingId,
|
||||
"PREPAID",
|
||||
);
|
||||
if (existing) await this.billing.cancelInvoice(existing.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* React to a booking invoice being paid — the settlement branch point. Per-type
|
||||
* reactions live here (not in the payment process): each invoice type advances
|
||||
@@ -108,6 +129,11 @@ export class BookingInvoiceService {
|
||||
await this.bookingBatch.reviveOfferForInvoice(payload.invoiceId);
|
||||
await this.advanceBookingOnPayment(payload.sourceId);
|
||||
break;
|
||||
case WAGON_CANCEL_FEE_INVOICE_TYPE:
|
||||
// Partial wagon cancellation: the fee settled — reduce the booking and
|
||||
// release the cancelled wagons (T2 of the cancellation cycle).
|
||||
await this.wagonCancellations.onFeePaid(payload.invoiceId);
|
||||
break;
|
||||
default:
|
||||
this.logger.warn(
|
||||
`Unhandled booking invoice type "${payload.type}" paid (${payload.invoiceId})`,
|
||||
|
||||
@@ -8,6 +8,9 @@ import {
|
||||
Optional,
|
||||
} from "@nestjs/common";
|
||||
import { EventEmitter2, OnEvent } from "@nestjs/event-emitter";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
|
||||
|
||||
import {
|
||||
BookingBatchService,
|
||||
@@ -56,11 +59,18 @@ export class BookingTransitionService {
|
||||
private readonly bookingClearanceService: BookingClearanceService,
|
||||
@Inject(forwardRef(() => ClearanceWorkflowService))
|
||||
private readonly workflowService: ClearanceWorkflowService,
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
// forwardRef: booking-invoice.service now pulls in the wagon-cancellation
|
||||
// service, whose cross-module imports close a require cycle through this
|
||||
// file — without it the class is undefined at decorator time.
|
||||
@Inject(forwardRef(() => BookingInvoiceService))
|
||||
private readonly invoiceService: BookingInvoiceService,
|
||||
private readonly containerValidationService: ContainerValidationService,
|
||||
private readonly notifier: BookingLifecycleNotifierService,
|
||||
private readonly events: EventEmitter2,
|
||||
@Optional() private readonly milestoneService?: ClearanceMilestoneService,
|
||||
// Optional + last so the hand-constructed service in *.spec.ts files keeps
|
||||
// compiling; Nest injects it normally at runtime.
|
||||
@Optional() private readonly dataSource?: DataSource,
|
||||
) {}
|
||||
|
||||
private isPhasedCustoms(booking: Booking): boolean {
|
||||
@@ -429,6 +439,20 @@ export class BookingTransitionService {
|
||||
return fresh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer self-service cancel, allowed only before payment — no fee.
|
||||
* SELECTED_FOR_BATCH releases the wagon hold immediately; earlier statuses
|
||||
* take the plain cancel path (open invoices expired, nothing reserved yet).
|
||||
* Anything past payment falls through to cancel()'s status assertion.
|
||||
*/
|
||||
async customerCancel(bookingId: string, reason?: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
if (booking.status === "SELECTED_FOR_BATCH") {
|
||||
return this.cancelHold(bookingId, reason);
|
||||
}
|
||||
return this.cancel(bookingId, reason ?? "Customer cancelled before payment");
|
||||
}
|
||||
|
||||
async cancel(bookingId: string, reason: string): Promise<Booking> {
|
||||
const booking = await this.bookingsService.findById(bookingId);
|
||||
assertBookingStatus(booking, [
|
||||
@@ -978,6 +1002,15 @@ export class BookingTransitionService {
|
||||
// booking through the space checks below AND is persisted so the accept /
|
||||
// reserve path locks onto that train (pickExportSchedule honors it).
|
||||
const requestedId = isExportTrain ? (requestedTrainScheduleId ?? null) : null;
|
||||
// Export rail rides the exact train the customer picked — never an
|
||||
// auto-assigned one. Both portal flows (clearance + contract completion)
|
||||
// surface a picker, so a missing id is an invalid submission, not a
|
||||
// legitimate "let the system choose".
|
||||
if (isExportTrain && !requestedId) {
|
||||
throw new BadRequestException(
|
||||
"Select a train for the chosen shipment day.",
|
||||
);
|
||||
}
|
||||
const scheduledBooking = {
|
||||
...booking,
|
||||
scheduledDate: date,
|
||||
@@ -1244,6 +1277,12 @@ export class BookingTransitionService {
|
||||
/** Flat list of physical container numbers on this booking (for the
|
||||
* customer truck-assignment container picker). */
|
||||
containerNumbers: string[];
|
||||
/** The allocated train, when the booking is placed on a schedule. */
|
||||
trainSchedule?: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null;
|
||||
}
|
||||
> {
|
||||
// This enrichment runs AFTER the transition has committed. A failure here
|
||||
@@ -1296,6 +1335,32 @@ export class BookingTransitionService {
|
||||
`enrichBookingResponse: batch-offer lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
// Allocated train: number + schedule reference for the detail headers
|
||||
// (portal and backoffice). Degrades to null like every fragile field here.
|
||||
let trainSchedule: {
|
||||
trainNumber: string | null;
|
||||
reference: string | null;
|
||||
scheduledDepartureDate: Date | null;
|
||||
} | null = null;
|
||||
if (booking.trainScheduleId && this.dataSource) {
|
||||
try {
|
||||
const s = await this.dataSource.getRepository(TrainSchedule).findOne({
|
||||
where: { id: booking.trainScheduleId },
|
||||
});
|
||||
if (s) {
|
||||
trainSchedule = {
|
||||
trainNumber: s.trainNumber ?? null,
|
||||
reference: s.reference ?? null,
|
||||
scheduledDepartureDate: s.scheduledDepartureDate ?? null,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`enrichBookingResponse: train-schedule lookup failed for ${booking.id}: ${(err as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Physical container numbers entered at booking time (booking_container
|
||||
// units), flattened for the customer truck-assignment container picker.
|
||||
const containerNumbers = (booking.bookingContainers ?? [])
|
||||
@@ -1310,6 +1375,7 @@ export class BookingTransitionService {
|
||||
nextStep,
|
||||
activeBatchOffer,
|
||||
containerNumbers,
|
||||
trainSchedule,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,85 @@
|
||||
import { BaseRepository } from '@edr/api-common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, SelectQueryBuilder } from 'typeorm';
|
||||
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
|
||||
export interface WagonCancellationListFilter {
|
||||
status?: string[];
|
||||
/** Booking reference / company name search (staff list). */
|
||||
search?: string;
|
||||
companyId?: string;
|
||||
bookingId?: string;
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BookingWagonCancellationsRepository extends BaseRepository<BookingWagonCancellation> {
|
||||
constructor(
|
||||
@InjectRepository(BookingWagonCancellation)
|
||||
repository: Repository<BookingWagonCancellation>,
|
||||
) {
|
||||
super(repository);
|
||||
}
|
||||
|
||||
/** The one open (fee-unpaid) cancellation of a booking, if any. */
|
||||
findOpenForBooking(bookingId: string): Promise<BookingWagonCancellation | null> {
|
||||
return this.repository.findOne({
|
||||
where: { bookingId, status: 'FEE_PENDING' },
|
||||
});
|
||||
}
|
||||
|
||||
findByFeeInvoiceId(feeInvoiceId: string): Promise<BookingWagonCancellation | null> {
|
||||
return this.repository.findOne({ where: { feeInvoiceId } });
|
||||
}
|
||||
|
||||
/** Paged history — staff see everything, customers are scoped by companyId. */
|
||||
async list(
|
||||
filter: WagonCancellationListFilter,
|
||||
): Promise<{ items: BookingWagonCancellation[]; total: number }> {
|
||||
const page = Math.max(1, filter.page ?? 1);
|
||||
const pageSize = Math.min(100, Math.max(1, filter.pageSize ?? 10));
|
||||
|
||||
const qb = this.baseQuery();
|
||||
if (filter.bookingId) {
|
||||
qb.andWhere('(bwc.booking_id = :bookingId OR bwc.rebooked_booking_id = :bookingId)', {
|
||||
bookingId: filter.bookingId,
|
||||
});
|
||||
}
|
||||
if (filter.companyId) {
|
||||
qb.andWhere('booking.company_id = :companyId', { companyId: filter.companyId });
|
||||
}
|
||||
if (filter.status?.length) {
|
||||
qb.andWhere('bwc.status IN (:...statuses)', { statuses: filter.status });
|
||||
}
|
||||
if (filter.search) {
|
||||
qb.andWhere('(booking.reference ILIKE :search OR company.name ILIKE :search)', {
|
||||
search: `%${filter.search}%`,
|
||||
});
|
||||
}
|
||||
if (filter.from) qb.andWhere('bwc.created_at >= :from', { from: filter.from });
|
||||
if (filter.to) qb.andWhere('bwc.created_at <= :to', { to: filter.to });
|
||||
|
||||
// Property path (not raw column): skip/take builds a distinct-id subquery
|
||||
// and the ORDER BY must resolve inside it.
|
||||
const [items, total] = await qb
|
||||
.orderBy('bwc.createdAt', 'DESC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize)
|
||||
.getManyAndCount();
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
private baseQuery(): SelectQueryBuilder<BookingWagonCancellation> {
|
||||
return this.repository
|
||||
.createQueryBuilder('bwc')
|
||||
.leftJoinAndSelect('bwc.booking', 'booking')
|
||||
.leftJoinAndSelect('booking.company', 'company')
|
||||
.leftJoinAndSelect('bwc.rebookedBooking', 'rebookedBooking')
|
||||
.leftJoinAndSelect('bwc.feeInvoice', 'feeInvoice');
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,12 @@ import { GenerateGrnDto } from './dto/generate-grn.dto';
|
||||
import { ContainerReceiptService } from './container-receipt.service';
|
||||
import { SignContractDto } from './dto/sign-contract.dto';
|
||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import {
|
||||
FilterWagonCancellationsDto,
|
||||
RebookCancelledWagonsDto,
|
||||
RequestWagonCancellationDto,
|
||||
} from './dto/wagon-cancellation.dto';
|
||||
import {
|
||||
type AuthUserPayload,
|
||||
resolveAuthUserId,
|
||||
@@ -151,6 +157,7 @@ export class BookingsController {
|
||||
private readonly firstMileService: FirstMileService,
|
||||
private readonly lastMileService: LastMileService,
|
||||
private readonly userTradeAccessService: UserTradeAccessService,
|
||||
private readonly wagonCancellationService: BookingWagonCancellationService,
|
||||
) {}
|
||||
|
||||
@Post()
|
||||
@@ -507,6 +514,150 @@ export class BookingsController {
|
||||
res.send(buffer);
|
||||
}
|
||||
|
||||
@Get(':id/wagons')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Allocated wagons for a booking (JSON) — empty until the paid booking is placed on a train',
|
||||
})
|
||||
async wagonAllocations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.bookingsService.wagonAllocations(id);
|
||||
}
|
||||
|
||||
// ── Partial wagon cancellation (paid bookings) ────────────────────────────
|
||||
// Customer endpoints are ownership-scoped (no portal permission keys); the
|
||||
// staff history/void/rebook variants are permission-gated below.
|
||||
|
||||
@Post(':id/wagon-cancellations/preview')
|
||||
@ApiOperation({ summary: 'Preview the fee/credit of a partial wagon cancellation (no writes)' })
|
||||
async previewWagonCancellation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestWagonCancellationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.previewCancellation(id, dto);
|
||||
}
|
||||
|
||||
@Post(':id/wagon-cancellations')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Request a partial wagon cancellation on a PAID booking — opens the cancellation-fee invoice; wagons are released only once the fee settles',
|
||||
})
|
||||
async requestWagonCancellation(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() dto: RequestWagonCancellationDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
if (!hasFreightPermission(user, FREIGHT_PERMS.bookings.view)) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.requestCancellation(id, dto, user?.id);
|
||||
}
|
||||
|
||||
@Get(':id/wagon-cancellations')
|
||||
@ApiOperation({ summary: 'Wagon-cancellation history of one booking (owner or staff)' })
|
||||
async listBookingWagonCancellations(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const booking = await this.bookingsService.findById(id);
|
||||
const staff =
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.view) ||
|
||||
hasFreightPermission(user, FREIGHT_PERMS.bookings.wagonCancellationView);
|
||||
if (!staff) {
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
return this.wagonCancellationService.list({ bookingId: id, pageSize: 100 });
|
||||
}
|
||||
|
||||
@Get('wagon-cancellations/my')
|
||||
@ApiOperation({ summary: 'Wagon-cancellation history of the calling customer (paginated, filterable)' })
|
||||
async listMyWagonCancellations(
|
||||
@Query() filter: FilterWagonCancellationsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
const companyId = await this.bookingsService.resolveCustomerCompanyId(user?.id ?? '');
|
||||
if (!companyId) throw new ForbiddenException('No customer company for this user.');
|
||||
return this.wagonCancellationService.list({
|
||||
companyId,
|
||||
status: filter.statuses,
|
||||
search: filter.search,
|
||||
from: filter.from ? new Date(filter.from) : undefined,
|
||||
to: filter.to ? new Date(filter.to) : undefined,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
@Get('wagon-cancellations/history')
|
||||
@WagonCancellationView()
|
||||
@ApiOperation({ summary: 'All wagon cancellations (staff, paginated, filterable)' })
|
||||
async listAllWagonCancellations(@Query() filter: FilterWagonCancellationsDto) {
|
||||
return this.wagonCancellationService.list({
|
||||
status: filter.statuses,
|
||||
search: filter.search,
|
||||
from: filter.from ? new Date(filter.from) : undefined,
|
||||
to: filter.to ? new Date(filter.to) : undefined,
|
||||
page: filter.page,
|
||||
pageSize: filter.pageSize,
|
||||
});
|
||||
}
|
||||
|
||||
@Post('wagon-cancellations/:cancellationId/withdraw')
|
||||
@ApiOperation({ summary: 'Withdraw a fee-pending wagon cancellation (owner, or staff with the void permission)' })
|
||||
async withdrawWagonCancellation(
|
||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationVoid,
|
||||
);
|
||||
return this.wagonCancellationService.withdraw(cancellationId);
|
||||
}
|
||||
|
||||
@Post('wagon-cancellations/:cancellationId/rebook')
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Rebook a wagon-cancellation credit: pick a shipment day only — the new booking is created under the contract and marked PAID (freight already paid; contract must still be valid)',
|
||||
})
|
||||
async rebookWagonCancellation(
|
||||
@Param('cancellationId', ParseUUIDPipe) cancellationId: string,
|
||||
@Body() dto: RebookCancelledWagonsDto,
|
||||
@CurrentUser() user: TCurrentUser,
|
||||
) {
|
||||
await this.assertWagonCancellationActor(
|
||||
cancellationId,
|
||||
user,
|
||||
FREIGHT_PERMS.bookings.wagonCancellationRebook,
|
||||
);
|
||||
return this.wagonCancellationService.rebook(cancellationId, dto, user?.id);
|
||||
}
|
||||
|
||||
/** Owner-or-staff gate shared by the per-cancellation actions. */
|
||||
private async assertWagonCancellationActor(
|
||||
cancellationId: string,
|
||||
user: TCurrentUser,
|
||||
staffPermission: string,
|
||||
): Promise<void> {
|
||||
if (hasFreightPermission(user, staffPermission)) return;
|
||||
const row = await this.wagonCancellationService.findById(cancellationId);
|
||||
const booking = await this.bookingsService.findById(row.bookingId);
|
||||
await this.bookingsService.assertCustomerCanAccessBooking(user?.id, booking);
|
||||
}
|
||||
|
||||
@Get(':id/customer-trucks')
|
||||
@MixedAudience([
|
||||
FREIGHT_PERMS.bookings.view,
|
||||
@@ -1382,6 +1533,19 @@ export class BookingsController {
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/customer-cancel")
|
||||
@ApiOperation({
|
||||
summary:
|
||||
"Customer cancels their own booking before payment — no cancellation fee",
|
||||
})
|
||||
async customerCancel(
|
||||
@Param("id", ParseUUIDPipe) id: string,
|
||||
@Body() dto: RejectBookingDto,
|
||||
) {
|
||||
const booking = await this.transitionService.customerCancel(id, dto.reason);
|
||||
return this.transitionService.enrichBookingResponse(booking);
|
||||
}
|
||||
|
||||
@Post(":id/cancel-hold")
|
||||
@PortalCustomer()
|
||||
@ApiOperation({
|
||||
|
||||
@@ -39,6 +39,9 @@ import { BookingRateSnapshot } from './entities/booking-rate-snapshot.entity';
|
||||
import { BookingContractSignature } from './entities/booking-contract-signature.entity';
|
||||
import { BookingReviewNote } from './entities/booking-review-note.entity';
|
||||
import { Booking } from './entities/booking.entity';
|
||||
import { BookingWagonCancellation } from './entities/booking-wagon-cancellation.entity';
|
||||
import { BookingWagonCancellationsRepository } from './booking-wagon-cancellations.repository';
|
||||
import { BookingWagonCancellationService } from './booking-wagon-cancellation.service';
|
||||
import { CustomerTruckAssignment } from './entities/customer-truck-assignment.entity';
|
||||
import { CustomerTruckContainer } from './entities/customer-truck-container.entity';
|
||||
import { CustomerTruckAssignmentsRepository } from './customer-truck-assignments.repository';
|
||||
@@ -66,6 +69,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
BookingReviewNote,
|
||||
BookingContractSignature,
|
||||
BookingContainerAllocation,
|
||||
BookingWagonCancellation,
|
||||
CustomerTruckAssignment,
|
||||
CustomerTruckContainer,
|
||||
]),
|
||||
@@ -110,6 +114,8 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
CustomerTruckAssignmentsRepository,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationsRepository,
|
||||
BookingWagonCancellationService,
|
||||
],
|
||||
exports: [
|
||||
BookingsService,
|
||||
@@ -121,6 +127,7 @@ import { VehiclesModule } from "../vehicles/vehicles.module";
|
||||
ConsolidationService,
|
||||
CustomerTruckService,
|
||||
ContainerReceiptService,
|
||||
BookingWagonCancellationService,
|
||||
],
|
||||
})
|
||||
export class BookingsModule { }
|
||||
|
||||
@@ -339,6 +339,64 @@ export class BookingsService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allocated wagons of a booking as JSON — the portal's "Wagons" tab. Same
|
||||
* join chain as the carriage acceptance sheet, but structured (containers as
|
||||
* an array per wagon, bulk load description when the wagon carries bulk).
|
||||
* Empty array until the booking has been allocated onto a train.
|
||||
*/
|
||||
async wagonAllocations(bookingId: string): Promise<unknown[]> {
|
||||
return this.dataSource.query(
|
||||
`SELECT a.id AS "allocationId",
|
||||
tsw.sequence_no AS "sequenceNo",
|
||||
w.wagon_number AS "wagonNumber",
|
||||
COALESCE(wt.name, wt.code) AS "wagonType",
|
||||
wt.code AS "wagonTypeCode",
|
||||
wt.tare_weight_tons AS "tareWeightTons",
|
||||
tsw.capacity_tons AS "capacityTons",
|
||||
tsw.length_meters AS "lengthMeters",
|
||||
a.allocated_weight_tons AS "allocatedWeightTons",
|
||||
a.load_type AS "loadType",
|
||||
a.status AS "status",
|
||||
s.train_number AS "trainNumber",
|
||||
s.scheduled_departure_date AS "departureAt",
|
||||
so.label AS "originStation",
|
||||
sd.label AS "destinationStation",
|
||||
bl.cargo_description AS "bulkCargoDescription",
|
||||
bl.quantity AS "bulkQuantity",
|
||||
COALESCE(
|
||||
json_agg(
|
||||
json_build_object(
|
||||
'containerNumber', ci.container_number,
|
||||
'sealNumber', ci.seal_number,
|
||||
'positionOnWagon', ci.position_on_wagon,
|
||||
'grossWeightTons', ci.gross_weight_tons
|
||||
) ORDER BY ci.position_on_wagon, ci.container_number
|
||||
) FILTER (WHERE ci.id IS NOT NULL),
|
||||
'[]'
|
||||
) AS "containers"
|
||||
FROM freight.wagon_booking_allocations a
|
||||
JOIN freight.train_set_wagons tsw
|
||||
ON tsw.id = a.train_set_wagon_id AND tsw.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_types wt ON wt.id = tsw.wagon_type_id
|
||||
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
|
||||
LEFT JOIN freight.train_schedules s
|
||||
ON s.train_set_id = tsw.train_set_id AND s.deleted_at IS NULL
|
||||
LEFT JOIN freight.yards so ON so.id = s.origin_station_id
|
||||
LEFT JOIN freight.yards sd ON sd.id = s.destination_station_id
|
||||
LEFT JOIN freight.wagon_allocation_container_items ci
|
||||
ON ci.wagon_booking_allocation_id = a.id AND ci.deleted_at IS NULL
|
||||
LEFT JOIN freight.wagon_allocation_bulk_loads bl
|
||||
ON bl.wagon_booking_allocation_id = a.id AND bl.deleted_at IS NULL
|
||||
WHERE a.booking_id = $1 AND a.deleted_at IS NULL
|
||||
GROUP BY tsw.id, a.id, w.wagon_number, wt.name, wt.code, wt.tare_weight_tons,
|
||||
s.train_number, s.scheduled_departure_date, so.label, sd.label,
|
||||
bl.cargo_description, bl.quantity
|
||||
ORDER BY tsw.sequence_no`,
|
||||
[bookingId],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the booking amount across its wagons, proportional to allocated weight
|
||||
* (equal shares when no weights are recorded). The last row absorbs the rounding
|
||||
@@ -1425,7 +1483,46 @@ export class BookingsService {
|
||||
tradeDirection,
|
||||
);
|
||||
}
|
||||
if (dto.scheduledDate) updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
// Re-pinning the departure day on an edit (e.g. fixing a CHANGES_REQUESTED
|
||||
// booking) must obey the same gate as creation: the route needs an OPEN
|
||||
// departure on that EAT day that can carry the cargo. Skipped when the day
|
||||
// didn't change, for general contracts (period-based, no pinned day) and
|
||||
// for intercity (staff assign a passing train later).
|
||||
if (dto.scheduledDate) {
|
||||
const day = eatDay(new Date(dto.scheduledDate));
|
||||
const dayChanged =
|
||||
!existing.scheduledDate || eatDay(existing.scheduledDate) !== day;
|
||||
if (
|
||||
dayChanged &&
|
||||
existing.bookingType !== 'GENERAL_CONTRACT' &&
|
||||
tradeDirection !== 'DOMESTIC'
|
||||
) {
|
||||
const { hasDeparture, hasCompatible } =
|
||||
await this.trainSchedulingService.checkDayCargoCompatibility(
|
||||
originYardId,
|
||||
destinationYardId,
|
||||
day,
|
||||
{
|
||||
freightType: freightType as 'CONTAINER' | 'BULK',
|
||||
cargoTypeId,
|
||||
containerTypeIds: containers
|
||||
.map((c) => c.containerTypeId)
|
||||
.filter((cid): cid is string => Boolean(cid)),
|
||||
},
|
||||
);
|
||||
if (!hasDeparture) {
|
||||
throw new BadRequestException(
|
||||
'No departures available on the selected day for this route',
|
||||
);
|
||||
}
|
||||
if (!hasCompatible) {
|
||||
throw new BadRequestException(
|
||||
'No wagon on the selected day can carry this cargo type — please choose another day',
|
||||
);
|
||||
}
|
||||
}
|
||||
updates.scheduledDate = new Date(dto.scheduledDate);
|
||||
}
|
||||
if (dto.estimatedShipmentDate)
|
||||
updates.estimatedShipmentDate = new Date(dto.estimatedShipmentDate);
|
||||
if (dto.startDate) updates.startDate = new Date(dto.startDate);
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayNotEmpty,
|
||||
IsArray,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { WAGON_CANCELLATION_STATUSES } from '../entities/booking-wagon-cancellation.entity';
|
||||
|
||||
export class CancelContainerLineDto {
|
||||
@ApiProperty({ description: 'Container size (ft) as stored on the booking line, e.g. "20", "40"' })
|
||||
@IsString()
|
||||
containerSize!: string;
|
||||
|
||||
@ApiProperty({ description: 'How many units of this size to cancel' })
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
quantity!: number;
|
||||
}
|
||||
|
||||
export class RequestWagonCancellationDto {
|
||||
@ApiPropertyOptional({
|
||||
description:
|
||||
'Cancel SPECIFIC allocated wagons: wagon_booking_allocation ids from GET /bookings/:id/wagons. ' +
|
||||
'When set, wagons/containers are derived from the selected wagons and the other fields are ignored.',
|
||||
type: [String],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@IsUUID('4', { each: true })
|
||||
wagonAllocationIds?: string[];
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'BULK bookings: number of wagons to cancel (tons derived proportionally)',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
@Min(0.5)
|
||||
wagons?: number;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'CONTAINER bookings: units to cancel per size (wagons derived per size)',
|
||||
type: [CancelContainerLineDto],
|
||||
})
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => CancelContainerLineDto)
|
||||
containers?: CancelContainerLineDto[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Customer reason for the cancellation' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class RebookCancelledWagonsDto {
|
||||
@ApiProperty({ description: 'Shipment day the credit is rebooked onto (ISO date)' })
|
||||
@IsDateString()
|
||||
scheduledDate!: string;
|
||||
}
|
||||
|
||||
export class FilterWagonCancellationsDto {
|
||||
@ApiPropertyOptional({ enum: WAGON_CANCELLATION_STATUSES, isArray: true })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsIn(WAGON_CANCELLATION_STATUSES as readonly string[], { each: true })
|
||||
statuses?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'Booking reference / company name search' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
from?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
to?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@ApiPropertyOptional({ default: 10 })
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { BaseEntity } from '@edr/api-common';
|
||||
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
|
||||
|
||||
import { Invoice } from '../../billing/entities/invoice.entity';
|
||||
import { Rate } from '../../rule-engine/entities/rate.entity';
|
||||
import { Booking } from './booking.entity';
|
||||
|
||||
export const WAGON_CANCELLATION_STATUSES = [
|
||||
// Requested; fee invoice open; wagons still allocated to the customer.
|
||||
'FEE_PENDING',
|
||||
// Fee settled; booking reduced, wagons freed; credit waiting for a rebook.
|
||||
'CREDIT_AVAILABLE',
|
||||
// Credit redeemed into a new PAID booking (rebookedBookingId).
|
||||
'REBOOKED',
|
||||
// Customer/staff voided the request before paying the fee. Nothing changed.
|
||||
'WITHDRAWN',
|
||||
// Reserved for a future expiry policy; not set by code today.
|
||||
'EXPIRED',
|
||||
] as const;
|
||||
|
||||
export type WagonCancellationStatus = (typeof WAGON_CANCELLATION_STATUSES)[number];
|
||||
|
||||
/** Snapshot of one physical container unit cut by the cancellation. */
|
||||
export interface CancelledUnitSnapshot {
|
||||
containerSize: string;
|
||||
containerNumber: string;
|
||||
sealNumber?: string | null;
|
||||
vgmTons: number;
|
||||
isHazardous: boolean;
|
||||
isReefer: boolean;
|
||||
}
|
||||
|
||||
/** What the cancellation cut, in the booking's own quantity terms. */
|
||||
export interface CancelledQuantities {
|
||||
/** Bulk bookings: tons cut (PER_ITEM cargo: item count, matching cargoTotalWeightVgm). */
|
||||
bulkTons?: number;
|
||||
/** Container bookings: units cut per container size. */
|
||||
bySize?: Record<string, number>;
|
||||
/**
|
||||
* Container bookings: the exact physical units cut. Snapshotted at request
|
||||
* time when the customer picked specific wagons, otherwise at fee settlement
|
||||
* (LIFO trim). The rebook reconstructs the new booking from THESE — never
|
||||
* from a soft-deleted-row scan, which could pick up units dropped by an
|
||||
* unrelated batch split on the same booking.
|
||||
*/
|
||||
units?: CancelledUnitSnapshot[];
|
||||
/**
|
||||
* Specific-wagon cancellation: the wagon_booking_allocation ids the customer
|
||||
* picked in the Wagons tab. T2 releases exactly these (fallback to
|
||||
* newest-first for any id that no longer exists, e.g. after a re-batch).
|
||||
*/
|
||||
allocationIds?: string[];
|
||||
/**
|
||||
* The wagon allocations were already released from the schedule at REQUEST
|
||||
* time (policy: wagons free up immediately; the fee is still owed before the
|
||||
* credit can be rebooked). Tells T2 to skip its release step so it never
|
||||
* deletes wagons the batch engine re-assigned in between.
|
||||
*/
|
||||
releasedAtRequest?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One partial-wagon-cancellation cycle on a PAID booking — the audit trail and
|
||||
* the state machine. The credit itself is not a wallet balance: redeeming it
|
||||
* creates a real booking through the under-contract create path and marks it
|
||||
* PAID (see BookingWagonCancellationService).
|
||||
*/
|
||||
@Entity({ schema: 'freight', name: 'booking_wagon_cancellations' })
|
||||
@Index(['bookingId'])
|
||||
@Index(['status'])
|
||||
export class BookingWagonCancellation extends BaseEntity {
|
||||
@Column({ name: 'booking_id', type: 'uuid' })
|
||||
bookingId!: string;
|
||||
|
||||
@ManyToOne(() => Booking)
|
||||
@JoinColumn({ name: 'booking_id' })
|
||||
booking?: Booking;
|
||||
|
||||
@Column({ name: 'rebooked_booking_id', type: 'uuid', nullable: true })
|
||||
rebookedBookingId?: string | null;
|
||||
|
||||
@ManyToOne(() => Booking, { nullable: true })
|
||||
@JoinColumn({ name: 'rebooked_booking_id' })
|
||||
rebookedBooking?: Booking | null;
|
||||
|
||||
@Column({ name: 'wagons_cancelled', type: 'numeric', precision: 6, scale: 2 })
|
||||
wagonsCancelled!: number;
|
||||
|
||||
@Column({ name: 'weight_tons', type: 'numeric', precision: 12, scale: 3, default: 0 })
|
||||
weightTons!: number;
|
||||
|
||||
@Column({ name: 'cancelled_quantities', type: 'jsonb' })
|
||||
cancelledQuantities!: CancelledQuantities;
|
||||
|
||||
/**
|
||||
* The freight value of the cancelled part at the ORIGINAL booking's price —
|
||||
* informational (shown to the customer as "credit worth"); no refund is ever
|
||||
* issued from it, the credit is redeemed by rebooking.
|
||||
*/
|
||||
@Column({ name: 'credit_amount', type: 'numeric', precision: 14, scale: 2, default: 0 })
|
||||
creditAmount!: number;
|
||||
|
||||
@Column({ name: 'fee_rate_id', type: 'uuid', nullable: true })
|
||||
feeRateId?: string | null;
|
||||
|
||||
@ManyToOne(() => Rate, { nullable: true })
|
||||
@JoinColumn({ name: 'fee_rate_id' })
|
||||
feeRate?: Rate | null;
|
||||
|
||||
@Column({ name: 'fee_amount', type: 'numeric', precision: 14, scale: 2 })
|
||||
feeAmount!: number;
|
||||
|
||||
@Column({ name: 'fee_currency', type: 'varchar', length: 8, default: 'ETB' })
|
||||
feeCurrency!: string;
|
||||
|
||||
@Column({ name: 'fee_invoice_id', type: 'uuid', nullable: true })
|
||||
feeInvoiceId?: string | null;
|
||||
|
||||
@ManyToOne(() => Invoice, { nullable: true })
|
||||
@JoinColumn({ name: 'fee_invoice_id' })
|
||||
feeInvoice?: Invoice | null;
|
||||
|
||||
@Column({ name: 'fee_paid_at', type: 'timestamptz', nullable: true })
|
||||
feePaidAt?: Date | null;
|
||||
|
||||
@Column({ name: 'status', type: 'varchar', length: 30, default: 'FEE_PENDING' })
|
||||
status!: string;
|
||||
|
||||
@Column({ name: 'reason', type: 'text', nullable: true })
|
||||
reason?: string | null;
|
||||
|
||||
@Column({ name: 'requested_by_user_id', type: 'uuid', nullable: true })
|
||||
requestedByUserId?: string | null;
|
||||
|
||||
@Column({ name: 'rebooked_at', type: 'timestamptz', nullable: true })
|
||||
rebookedAt?: Date | null;
|
||||
}
|
||||
Reference in New Issue
Block a user