mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 13:38:20 +00:00
Merge pull request #1434 from Tria-plc/freight_feature/usermanagement
Freight feature/usermanagement
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user