Merge pull request #1434 from Tria-plc/freight_feature/usermanagement

Freight feature/usermanagement
This commit is contained in:
marshal
2026-08-28 10:34:31 +03:00
committed by GitHub
32 changed files with 1762 additions and 646 deletions

View File

@@ -5,7 +5,7 @@ import {
NotFoundException,
Optional,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { EventEmitter2, OnEvent } from '@nestjs/event-emitter';
import { InjectDataSource } from '@nestjs/typeorm';
import { DataSource, EntityManager, In } from 'typeorm';
import { Freight } from '@edr/types';
@@ -72,6 +72,117 @@ export class BookingJourneyService {
async loadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingLoadable(schedule, booking);
return this.completeLoad(schedule, booking, userId ?? null);
}
/**
* Confirm ONE wagon of the booking loaded (per-wagon loading). The booking
* stays PAID while wagons remain; loading the last remaining wagon runs the
* whole-booking completion (IN_TRANSIT, warehouse inventory, GRN,
* milestones) exactly as the one-shot load does. Wagons that will NOT ride
* must be cancelled via the at-loading cancellation before the booking can
* complete (and before the train may dispatch).
*/
async loadWagon(
scheduleId: string,
bookingId: string,
allocationId: string,
userId?: string | null,
) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingLoadable(schedule, booking);
const allocations = await this.allocationsForBooking(
this.dataSource.manager,
scheduleId,
bookingId,
);
if (!allocations.length) {
throw new BadRequestException(
'This booking has no wagon allocations on the schedule — use the whole-booking load.',
);
}
const target = allocations.find((a) => a.id === allocationId);
if (!target) {
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
}
if (target.status === 'LOADED' || target.status === 'DEPARTED') {
throw new BadRequestException('This wagon is already loaded');
}
const now = new Date();
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WagonBookingAllocation).update(target.id, {
status: 'LOADED',
loadedAt: now,
loadedByUserId: userId ?? null,
});
if (!booking.loadingStartedAt) {
await manager
.getRepository(Booking)
.update(bookingId, { loadingStartedAt: now } as never);
}
// PARTIAL keeps the dispatch-readiness badge honest; completion below
// flips it to LOADED.
await manager
.getRepository(TrainScheduleBooking)
.update({ trainScheduleId: scheduleId, bookingId }, { loadingStatus: 'PARTIAL' });
});
const remaining = allocations.filter(
(a) => a.id !== target.id && a.status !== 'LOADED' && a.status !== 'DEPARTED',
).length;
if (remaining === 0) {
const done = await this.completeLoad(schedule, booking, userId ?? null);
return {
...done,
allocationId,
loadedWagons: allocations.length,
totalWagons: allocations.length,
completed: true,
};
}
return {
bookingId,
allocationId,
status: booking.status,
loadedWagons: allocations.length - remaining,
totalWagons: allocations.length,
completed: false,
};
}
/**
* The at-loading cancel shrank the booking to its loaded wagons — if every
* wagon left on it is LOADED, the load is complete: run the whole-booking
* completion. Fired by BookingWagonCancellationService.cancelRemainingAtLoading.
*/
@OnEvent('booking.wagonsCancelledAtLoading')
async onWagonsCancelledAtLoading(payload: {
bookingId: string;
scheduleId: string;
userId?: string | null;
}): Promise<void> {
try {
const allocations = await this.allocationsForBooking(
this.dataSource.manager,
payload.scheduleId,
payload.bookingId,
);
const loaded = allocations.filter(
(a) => a.status === 'LOADED' || a.status === 'DEPARTED',
).length;
if (!allocations.length || loaded < allocations.length) return;
await this.loadBooking(payload.scheduleId, payload.bookingId, payload.userId);
} catch (err) {
this.logger.error(
`Post-cancel load completion failed for booking ${payload.bookingId}: ${err instanceof Error ? err.message : String(err)}`,
);
}
}
/** The pre-load gates shared by whole-booking and per-wagon loading. */
private async assertBookingLoadable(schedule: TrainSchedule, booking: Booking): Promise<void> {
if (booking.loadedAt || booking.status === 'IN_TRANSIT') {
throw new BadRequestException('Booking is already loaded');
}
@@ -86,6 +197,16 @@ export class BookingJourneyService {
// Export cargo must be in the warehouse with a GRN before it can be loaded,
// however it arrived and whatever it is allocated to.
await assertExportReceivedWithGrn(this.dataSource, booking);
}
/** The whole-booking load side effects — gates already passed. */
private async completeLoad(
schedule: TrainSchedule,
booking: Booking,
userId: string | null,
) {
const scheduleId = schedule.id;
const bookingId = booking.id;
// Direct truck-to-train cargo never sees the warehouse, so loading IS its
// handover moment — the carriage acceptance sheet must go out to the
// customer right here, not on a receive event that will never fire.
@@ -165,6 +286,77 @@ export class BookingJourneyService {
async unloadBooking(scheduleId: string, bookingId: string, userId?: string | null) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingUnloadable(schedule, booking);
return this.completeUnload(schedule, booking, userId ?? null);
}
/**
* Confirm ONE wagon of the booking unloaded (per-wagon unloading). Tracking
* only while wagons remain on the train — the booking stays IN_TRANSIT;
* unloading the last wagon runs the whole-booking completion (ARRIVED/
* COMPLETED, wagon settlement, events) exactly as the one-shot unload does.
*/
async unloadWagon(
scheduleId: string,
bookingId: string,
allocationId: string,
userId?: string | null,
) {
const { schedule, booking } = await this.getScheduleBooking(scheduleId, bookingId);
await this.assertBookingUnloadable(schedule, booking);
const allocations = await this.allocationsForBooking(
this.dataSource.manager,
scheduleId,
bookingId,
);
if (!allocations.length) {
throw new BadRequestException(
'This booking has no wagon allocations on the schedule — use the whole-booking unload.',
);
}
const target = allocations.find((a) => a.id === allocationId);
if (!target) {
throw new NotFoundException('Wagon allocation not found on this booking/schedule');
}
if (target.status === 'DEPARTED') {
throw new BadRequestException('This wagon is already unloaded');
}
const now = new Date();
await this.dataSource.getRepository(WagonBookingAllocation).update(target.id, {
status: 'DEPARTED',
unloadedAt: now,
unloadedByUserId: userId ?? null,
});
const remaining = allocations.filter(
(a) => a.id !== target.id && a.status !== 'DEPARTED',
).length;
if (remaining === 0) {
const done = await this.completeUnload(schedule, booking, userId ?? null);
return {
...done,
allocationId,
unloadedWagons: allocations.length,
totalWagons: allocations.length,
completed: true,
};
}
return {
bookingId,
allocationId,
status: booking.status,
unloadedWagons: allocations.length - remaining,
totalWagons: allocations.length,
completed: false,
};
}
/** The pre-unload gates shared by whole-booking and per-wagon unloading. */
private async assertBookingUnloadable(
schedule: TrainSchedule,
booking: Booking,
): Promise<void> {
if (booking.status !== 'IN_TRANSIT') {
throw new BadRequestException(
`Booking must be loaded/in transit before unloading (currently ${booking.status})`,
@@ -173,7 +365,16 @@ export class BookingJourneyService {
await this.assertTrainAtYard(schedule, booking.destinationYardId, 'destination');
this.assertStationWorkStarted(schedule, booking.destinationYardId, 'unloading');
await this.assertYardCanHandleCargo(booking, booking.destinationYardId, 'destination');
}
/** The whole-booking unload side effects — gates already passed. */
private async completeUnload(
schedule: TrainSchedule,
booking: Booking,
userId: string | null,
) {
const scheduleId = schedule.id;
const bookingId = booking.id;
// Intercity has no clearance/delivery tail — unloading completes it. Import/
// export continue into clearance, keyed on the booking's own arrival.
const nextStatus = booking.tradeDirection === 'DOMESTIC' ? 'COMPLETED' : 'ARRIVED';
@@ -498,7 +699,17 @@ export class BookingJourneyService {
.findOne({ where: { id: bookingId } });
if (!booking) throw new NotFoundException(`Booking ${bookingId} not found`);
if (booking.trainScheduleId !== scheduleId) {
throw new BadRequestException('Booking is not assigned to this schedule');
// The schedule↔booking LINK is the same authority the workspace list
// (listYardWork) renders from — some flows (export train pick) create it
// with wagon allocations before bookings.train_schedule_id is stamped.
// Trusting only the column made those rows show a Load button that
// always 400'd.
const linked = await this.dataSource.getRepository(TrainScheduleBooking).findOne({
where: { trainScheduleId: scheduleId, bookingId },
});
if (!linked) {
throw new BadRequestException('Booking is not assigned to this schedule');
}
}
return { schedule, booking };
}

View File

@@ -707,6 +707,46 @@ export class TrainSchedulingController {
return this.bookingJourneyService.unloadBooking(id, bookingId);
}
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/load")
@TrainSchedulingLoad()
@ApiOperation({
summary:
"Confirm ONE wagon of the booking loaded (per-wagon loading). The booking stays PAID until every remaining wagon is LOADED; the last wagon runs the whole-booking load completion.",
})
loadScheduleBookingWagon(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@Param("allocationId", ParseUUIDPipe) allocationId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.loadWagon(
id,
bookingId,
allocationId,
resolveAuthUserId(user),
);
}
@Post("schedules/:id/bookings/:bookingId/wagons/:allocationId/unload")
@TrainSchedulingUnload()
@ApiOperation({
summary:
"Confirm ONE wagon of the booking unloaded (per-wagon unloading). The booking stays IN_TRANSIT until the last wagon, which runs the whole-booking unload completion.",
})
unloadScheduleBookingWagon(
@Param("id", ParseUUIDPipe) id: string,
@Param("bookingId", ParseUUIDPipe) bookingId: string,
@Param("allocationId", ParseUUIDPipe) allocationId: string,
@CurrentUser() user: AuthUserPayload,
) {
return this.bookingJourneyService.unloadWagon(
id,
bookingId,
allocationId,
resolveAuthUserId(user),
);
}
@Post("schedules/:id/intercity/:bookingId/load")
@TrainSchedulingLoad()
@ApiOperation({

View File

@@ -2946,6 +2946,10 @@ export class TrainSchedulingService {
// The yard plan this departure was SOLD against must match where the steel
// actually stands: a wagon sold from Dire but still in Mojo cannot board.
await this.assertPlannedYardsAligned(schedule);
// Per-wagon loading: a booking mid-load is neither ridable nor removable —
// every wagon must be LOADED, or the never-loaded remainder cancelled
// (at-loading cancellation), before the train departs.
await this.assertNoPartiallyLoadedBookings(schedule);
await this.dataSource.transaction(async (manager) => {
const trainNumber = await this.assignTrainNumber(manager, schedule);
@@ -3144,6 +3148,42 @@ export class TrainSchedulingService {
* PAID — or FULLY_EXECUTED for shipping-line bookings, which never prepay
* (their charge sits on the credit ledger) yet ride from accept.
*/
/**
* Per-wagon loading dispatch gate: a booking with SOME wagons LOADED and
* SOME still PLANNED/RESERVED must resolve before departure — load the rest
* or cancel it (which shrinks the booking to its loaded wagons). Blocking
* here beats silently unassigning: unassign would delete LOADED allocations
* and strand cargo that is physically on the train.
*/
private async assertNoPartiallyLoadedBookings(schedule: TrainSchedule): Promise<void> {
if (!schedule.trainSetId) return;
const rows: Array<{ reference: string; loaded: string; total: string }> =
await this.dataSource.query(
`SELECT b.reference,
COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) AS loaded,
COUNT(*) AS total
FROM freight.wagon_booking_allocations a
JOIN freight.train_set_wagons tsw ON tsw.id = a.train_set_wagon_id
JOIN freight.bookings b ON b.id = a.booking_id
WHERE tsw.train_set_id = $1
AND a.deleted_at IS NULL
AND tsw.deleted_at IS NULL
AND b.deleted_at IS NULL
GROUP BY b.id, b.reference
HAVING COUNT(*) FILTER (WHERE a.status IN ('LOADED', 'DEPARTED')) > 0
AND COUNT(*) FILTER (WHERE a.status NOT IN ('LOADED', 'DEPARTED')) > 0`,
[schedule.trainSetId],
);
if (rows.length) {
const detail = rows
.map((r) => `${r.reference} (${r.loaded}/${r.total} wagons loaded)`)
.join(', ');
throw new BadRequestException(
`Cannot dispatch: booking(s) partially loaded — load every wagon or cancel the remainder first: ${detail}`,
);
}
}
private async unloadedOriginBoarderIds(
scheduleId: string,
originYardId: string,
@@ -3157,6 +3197,7 @@ export class TrainSchedulingService {
AND b.deleted_at IS NULL
AND b.origin_yard_id = $2
AND b.loaded_at IS NULL
AND b.loading_started_at IS NULL
AND COALESCE(tsb.loading_status, 'UNLOADED') <> 'LOADED'
AND b.is_government = false
AND (b.status = 'PAID'