Merge branch 'dev' into freight/nati-2

This commit is contained in:
Nathnael
2026-08-07 08:09:34 +00:00
79 changed files with 7273 additions and 802 deletions

View File

@@ -35,10 +35,57 @@ export class FreightMeService {
}
}
/**
* Permissions granted to the position's TYPE (`iam.position_type_permissions`).
* A position type is the platform's notion of a role, and admin-created
* positions carry their grants there rather than on the position itself — but
* the JWT only ever snapshots direct position permissions. Without this, staff
* on such a position resolve to zero permissions and every permission-gated
* route rejects them (this is what locked GL officers out of their clearance
* detail pages). Resolved live from IAM, same as the position type above.
*/
private async lookupPositionTypePermissions(
positionId: string | undefined,
): Promise<string[]> {
if (!positionId) return [];
try {
const rows: { key: string }[] = await this.dataSource.query(
`SELECT DISTINCT perm.key
FROM iam.positions p
JOIN iam.position_type_permissions ptp
ON ptp.position_type_id = p.position_type_id
JOIN iam.permissions perm ON perm.id = ptp.permission_id
WHERE p.id = $1`,
[positionId],
);
return rows.map((r) => r.key).filter(Boolean);
} catch {
return []; // iam schema unreachable — degrade to position-only permissions
}
}
async getEnrichedProfile(user: TCurrentUser) {
const positionType = await this.lookupPositionType(
user.employee?.position?.id,
const positionId = user.employee?.position?.id;
const [positionType, positionTypePermissionKeys] = await Promise.all([
this.lookupPositionType(positionId),
this.lookupPositionTypePermissions(positionId),
]);
// Merge the type-level grants into the position's own permission list so
// BOTH consumers see them: `collectPermissionKeys` below, and the
// backoffice's `getPermissionKeys`, which walks this same nested array.
const positionPermissions = [
...(user.employee?.position?.permissions ?? []),
];
const seenPermissionKeys = new Set(
positionPermissions.map((p) => p?.key).filter(Boolean),
);
for (const key of positionTypePermissionKeys) {
if (!seenPermissionKeys.has(key)) {
seenPermissionKeys.add(key);
positionPermissions.push({ key } as (typeof positionPermissions)[number]);
}
}
const employee = user.employee
? [
@@ -56,7 +103,7 @@ export class FreightMeService {
name: user.employee.position.name,
isDelegate: user.employee.position.isDelegate,
parentPositionId: user.employee.position.parentPositionId,
permissions: user.employee.position.permissions ?? [],
permissions: positionPermissions,
positionType,
},
]
@@ -65,7 +112,15 @@ export class FreightMeService {
]
: [];
const permissionKeys = collectPermissionKeys(user);
// `collectPermissionKeys` reads the raw token (position-level only), so
// union the type-level grants in — the backoffice prefers this flat list
// over the nested array and would otherwise still see none of them.
const permissionKeys = [
...new Set([
...collectPermissionKeys(user),
...positionTypePermissionKeys,
]),
];
return {
id: user.id,

View File

@@ -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})`,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,115 @@
import { ContractBookingService } from './contract-booking.service';
/**
* OPERATION_CHANGES_REQUESTED resubmit with restated cargo. Operations can ask
* for the cargo itself to change, so a completion payload that restates
* containers must cancel the unpaid invoice, wipe the persisted cargo and
* re-run the fresh-completion path (re-persist, re-price, re-invoice). A
* payload without cargo keeps the day-only resubmit behavior.
*/
describe('ContractBookingService — changes-requested resubmit restating cargo', () => {
const CONTRACT = {
id: 'c-1',
reference: 'CTR-1',
contractKind: 'GENERAL',
freightType: 'CONTAINER',
tradeDirection: 'IMPORT',
customsClearingEnabled: false,
contractValidUntil: null,
cargoScope: [],
};
const bookingWithCargo = () => ({
id: 'b-1',
contractId: 'c-1',
reference: 'BKG-1',
status: 'OPERATION_CHANGES_REQUESTED',
bookingContainers: [{ containerSize: '20FT', quantity: 4 }],
cargoTotalWeightVgm: 80,
originYardId: 'y-o',
destinationYardId: 'y-d',
});
function makeService() {
const bookingsRepository = {
findByIdWithFiles: jest.fn().mockResolvedValue(bookingWithCargo()),
deleteContainers: jest.fn().mockResolvedValue(undefined),
update: jest.fn().mockResolvedValue(undefined),
};
const invoiceService = {
cancelUnpaidInvoiceForBooking: jest.fn().mockResolvedValue(undefined),
};
const trainSchedulingService = {
assertBookingWindowOpen: jest.fn().mockResolvedValue(undefined),
};
const contractsRepository = {
findByIdWithRelations: jest.fn().mockResolvedValue(CONTRACT),
};
const service = new ContractBookingService(
contractsRepository as never,
bookingsRepository as never,
{} as never, // bookingPricingService
{} as never, // consolidationService
{} as never, // containerTypesService
{} as never, // ruleEngineService
{} as never, // milestoneService
invoiceService as never,
{} as never, // bookingNotifier
{} as never, // dataSource
trainSchedulingService as never,
{} as never, // bookingBatchService
{} as never, // bookingTransitionService
);
return { service, bookingsRepository, invoiceService };
}
// Both paths dead-end into a downstream private assert we replace with a
// sentinel — which path threw tells us which branch the resubmit took.
const SENTINEL = new Error('reached-branch');
it('restated cargo cancels the invoice, wipes cargo and re-runs fresh completion', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First gate inside the fresh-completion (!hasCargo) path.
jest
.spyOn(
service as never as { assertWithinQuantityCap: () => Promise<void> },
'assertWithinQuantityCap',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
containers: [{ containerSize: '20FT', quantity: 2 }],
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.deleteContainers).toHaveBeenCalledWith('b-1');
expect(bookingsRepository.update).toHaveBeenCalledWith('b-1', {
cargoTotalWeightVgm: 0,
});
});
it('a day-only resubmit keeps the persisted cargo and invoice untouched', async () => {
const { service, bookingsRepository, invoiceService } = makeService();
// First call inside the day-only (hasCargo) resubmit path.
jest
.spyOn(
service as never as {
assertPersistedContainersAvailable: () => Promise<void>;
},
'assertPersistedContainersAvailable',
)
.mockRejectedValue(SENTINEL);
await expect(
service.completeUnderContract('c-1', 'b-1', {
scheduledDate: new Date().toISOString(),
} as never),
).rejects.toBe(SENTINEL);
expect(invoiceService.cancelUnpaidInvoiceForBooking).not.toHaveBeenCalled();
expect(bookingsRepository.deleteContainers).not.toHaveBeenCalled();
});
});

View File

@@ -98,6 +98,9 @@ export class ContractBookingService {
private readonly containerTypesService: ContainerTypesService,
private readonly ruleEngineService: RuleEngineService,
private readonly milestoneService: ClearanceMilestoneService,
// forwardRef: part of the booking-invoice ⇄ wagon-cancellation ⇄ contracts
// require cycle (see BookingTransitionService).
@Inject(forwardRef(() => BookingInvoiceService))
private readonly invoiceService: BookingInvoiceService,
private readonly bookingNotifier: BookingLifecycleNotifierService,
private readonly dataSource: DataSource,
@@ -692,11 +695,30 @@ export class ContractBookingService {
});
const freightType = contract.freightType;
const hasCargo =
let hasCargo =
(booking.bookingContainers?.length ?? 0) > 0 ||
Number(booking.cargoTotalWeightVgm) > 0;
const warnings: string[] = [];
// Operations may return a booking asking for the CARGO to change (fewer or
// more containers), not just the day. A resubmit whose payload restates the
// cargo therefore starts the completion over: cancel the unpaid invoice
// first (it throws if money is already recorded — cargo must not change
// under a paid invoice), then wipe the persisted cargo so the fresh-
// completion path below re-persists, re-prices and re-invoices from the
// payload. A resubmit without cargo keeps today's day-only behavior.
const restatesCargo = Boolean(
dto.containers?.length || dto.bulkLines?.length,
);
if (hasCargo && restatesCargo) {
await this.invoiceService.cancelUnpaidInvoiceForBooking(booking.id);
await this.bookingsRepository.deleteContainers(booking.id);
await this.bookingsRepository.update(booking.id, {
cargoTotalWeightVgm: 0,
} as never);
hasCargo = false;
}
// EXPORT rides whole or not at all (no split concept): the chosen day must
// have a single open train that carries the whole booking. First completion
// sizes from the dto's cargo; a changes-requested resubmit (cargo already
@@ -1863,6 +1885,9 @@ export class ContractBookingService {
async validateShipment(
contractId: string,
dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: the booking being completed must not clash
// with its own persisted containers.
excludeBookingId?: string,
): Promise<{
overweightLines: Array<{
containerTypeCode: string;
@@ -2024,6 +2049,7 @@ export class ContractBookingService {
originYardId: route?.originYardId,
destinationYardId: route?.destinationYardId,
},
excludeBookingId,
);
containerClashErrors = clashes.map(
(c) =>

View File

@@ -1145,8 +1145,11 @@ export class ContractsController {
validateShipment(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: CreateBookingUnderContractDto,
// Completion/resubmit preview: exclude this booking's own persisted
// containers from the same-train clash check.
@Query('bookingId') bookingId?: string,
) {
return this.contractBookingService.validateShipment(id, dto);
return this.contractBookingService.validateShipment(id, dto, bookingId);
}
@Get(':id/capacity')

View File

@@ -31,6 +31,19 @@ export function paymentDrainMs(): number {
);
}
/**
* ISO timestamp of the end of a pay window's drain tail, for client display
* (the "payment processing" countdown). Null in ⇒ null out.
*/
export function paymentDrainEndsAtIso(
deadline: Date | string | null | undefined,
): string | null {
if (deadline == null) return null;
const ms = new Date(deadline).getTime();
if (!Number.isFinite(ms)) return null;
return new Date(ms + paymentDrainMs()).toISOString();
}
/**
* A pay window AND its drain tail have closed.
*

View File

@@ -14,6 +14,7 @@ import { Server, Socket } from 'socket.io';
import { WsAuthService } from '../notification-inbox/ws-auth.service';
import { TrainSchedule } from '../train-schedules/entities/train-schedule.entity';
import { paymentDrainEndsAtIso } from './booking-batch.constants';
/**
* Server → client push for booking-window state changes. Same handshake model
@@ -61,6 +62,7 @@ export class BookingWindowGateway implements OnGatewayConnection {
windowClosesAt: schedule.windowClosesAt?.toISOString() ?? null,
docReviewEndsAt: schedule.docReviewEndsAt?.toISOString() ?? null,
paymentPhaseEndsAt: schedule.paymentPhaseEndsAt?.toISOString() ?? null,
paymentDrainEndsAt: paymentDrainEndsAtIso(schedule.paymentPhaseEndsAt),
scheduledDepartureDate: schedule.scheduledDepartureDate?.toISOString() ?? null,
};
this.server.emit(BOOKING_WINDOW_WS_EVENTS.PHASE, payload);

View File

@@ -1,5 +1,6 @@
import {
DEFAULT_PAYMENT_DRAIN_MINUTES,
paymentDrainEndsAtIso,
paymentDrainMs,
payWindowLapsed,
} from "./booking-batch.constants";
@@ -64,4 +65,16 @@ describe("payWindowLapsed — pay-window drain tail", () => {
expect(paymentDrainMs()).toBe(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN);
}
});
it("paymentDrainEndsAtIso reports deadline + drain, null/garbage-safe", () => {
expect(paymentDrainEndsAtIso(deadline)).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(deadline.toISOString())).toBe(
new Date(at(DEFAULT_PAYMENT_DRAIN_MINUTES * MIN)).toISOString(),
);
expect(paymentDrainEndsAtIso(null)).toBeNull();
expect(paymentDrainEndsAtIso(undefined)).toBeNull();
expect(paymentDrainEndsAtIso("not-a-date")).toBeNull();
});
});

View File

@@ -153,6 +153,7 @@ import {
DEFAULT_CONTAINER_WAGON_CAPACITY_TONS,
DEFAULT_CONTAINER_WAGON_LENGTH_METERS,
DEFAULT_CONTAINER_WAGON_TARE_TONS,
paymentDrainEndsAtIso,
} from './booking-batch.constants';
import { orderConsistWagons } from './consist-order.util';
import {
@@ -1546,23 +1547,10 @@ export class TrainSchedulingService {
}
: globalCfg;
// Staff cannot schedule inside the lead window — there must be room for a
// booking window before departure. IMPORT/DOMESTIC lead is in whole EAT
// days (lead 3, today 11th → first allowed departure is the 14th); EXPORT
// lead is in hours (24h = 1 day ahead). Checked against the schedule's OWN
// lead, so a custom lead is honoured rather than rejected by the global one.
const earliest = earliestSchedulableDeparture(direction, windowCfg, new Date());
if (departure.getTime() < earliest.getTime()) {
const detail =
direction === 'EXPORT'
? `at least ${windowCfg.exportBookingLeadHours} hour(s) ahead`
: `at least ${windowCfg.importWindowLeadDays} day(s) ahead`;
throw new BadRequestException(
`Departure ${departure.toISOString()} is inside the booking lead window; ` +
`${direction === 'EXPORT' ? 'export' : 'import'} trains must be scheduled ${detail} ` +
`(earliest ${earliest.toISOString()})`,
);
}
// Short-notice trains are allowed: a departure inside the booking lead
// window is NOT rejected — the window just opens immediately (opensAt is
// clamped to `now` below) instead of waiting out a lead that has already
// passed. Only `updateScheduleDate` still enforces the lead floor.
// Freeze the rule this schedule is born with. A later global-rules edit
// only re-derives NOT-YET-OPEN schedules (see restampPendingWindows); an
@@ -1577,6 +1565,11 @@ export class TrainSchedulingService {
...ruleSnapshot,
...computeImportWindowTimes(departure, windowCfg, new Date()),
};
// Inside-lead departure (e.g. a huge configured lead): the raw open lands
// in the past — clamp it to `now` so the window tick opens it immediately.
if (computedTimes.windowOpensAt.getTime() < Date.now()) {
computedTimes.windowOpensAt = new Date();
}
if (
computedTimes.windowOpensAt.getTime() >= computedTimes.windowClosesAt.getTime()
) {
@@ -6948,6 +6941,7 @@ export class TrainSchedulingService {
windowClosesAt: r.window_closes_at,
docReviewEndsAt: r.doc_review_ends_at,
paymentPhaseEndsAt: r.payment_phase_ends_at,
paymentDrainEndsAt: paymentDrainEndsAtIso(r.payment_phase_ends_at),
bookingWindowStatus: r.booking_window_status,
bookingCycleNo: r.booking_cycle_no,
departureDate: r.scheduled_departure_date,