mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 06:00:55 +00:00
feat(export): gate receive on payment and loading on received + GRN
Three export rules that the flow left open. An unpaid export booking could be received at the warehouse. Receiving is what starts storage and mints a GRN, so it must not happen against cargo the customer has not settled. receive() now rejects an unpaid EXPORT booking. Import is untouched — it arrives OFF a train and its receive is the unload, so gating that on payment would strand cargo already at the yard. An allocated export booking could be marked loaded onto its train without ever reaching the warehouse. An allocation is a plan; the GRN is the proof the goods are in hand. Two loading paths skipped that check — the per-yard loadBooking and the workspace confirmScheduleLoading — and both now require every export booking to be received with a GRN first, however it arrived (first-mile or the customer's own truck) and whatever it is allocated to. The rule lives in one shared guard (assertExportReceivedWithGrn) so the two paths cannot drift. Export self-haul without a first-mile leg already worked and is unchanged: assertSelfHaulPaid allows a customer truck when there is no EDR mile leg and the booking is paid, and addTruck applies the same one-40ft-or-two-20ft rule to containers and the tonnage drawdown to bulk, exactly as import does. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import { WagonBookingAllocation } from '../train-schedules/entities/wagon-bookin
|
||||
import { Wagon } from '../wagons/entities/wagon.entity';
|
||||
import { WagonMovement } from '../wagons/entities/wagon-movement.entity';
|
||||
import { TrainCheckpointEvent } from './entities/train-checkpoint-event.entity';
|
||||
import { assertExportReceivedWithGrn } from '../../common/export-received-gate';
|
||||
|
||||
/**
|
||||
* Per-booking journey along a train's corridor — for EVERY trade direction.
|
||||
@@ -68,6 +69,9 @@ export class BookingJourneyService {
|
||||
}
|
||||
await this.assertTrainAtYard(schedule, booking.originYardId, 'origin');
|
||||
await this.assertYardCanHandleCargo(booking, booking.originYardId, 'origin');
|
||||
// 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);
|
||||
|
||||
const now = new Date();
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
|
||||
@@ -2632,6 +2632,11 @@ export class TrainSchedulingService {
|
||||
// dispatch pre-check keeps reporting these bookings as unloaded).
|
||||
const wagonAssignedIds = await this.getWagonAssignedBookingIds(scheduleId);
|
||||
if (wagonAssignedIds.size) {
|
||||
// Export cargo must be received at the warehouse with a GRN before it can
|
||||
// be confirmed loaded — an allocation is not proof the goods are in hand.
|
||||
if (this.isExportSchedule(schedule)) {
|
||||
await this.assertExportBookingsReceived([...wagonAssignedIds]);
|
||||
}
|
||||
await this.trainScheduleBookingsRepository.updateLoadingStatusMany(
|
||||
scheduleId,
|
||||
[...wagonAssignedIds],
|
||||
@@ -2909,6 +2914,38 @@ export class TrainSchedulingService {
|
||||
return direction === 'EXPORT';
|
||||
}
|
||||
|
||||
/**
|
||||
* Every export booking being confirmed loaded must already be received at the
|
||||
* warehouse with a GRN. An allocation puts a booking on a wagon on paper; this
|
||||
* is the check that the cargo is physically in the yard before we call it loaded.
|
||||
*/
|
||||
private async assertExportBookingsReceived(bookingIds: string[]): Promise<void> {
|
||||
if (!bookingIds.length) return;
|
||||
const rows: Array<{ reference: string | null }> = await this.dataSource.query(
|
||||
`SELECT b.reference
|
||||
FROM freight.bookings b
|
||||
WHERE b.id = ANY($1)
|
||||
AND b.deleted_at IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM freight.warehouse_inventory inv
|
||||
WHERE inv.booking_id = b.id
|
||||
AND inv.deleted_at IS NULL
|
||||
AND inv.status IN ('RECEIVED','STORED','RESERVED','READY_FOR_LOADING','LOADED','DISPATCHED')
|
||||
AND COALESCE(
|
||||
NULLIF(TRIM(inv.grn_number), ''),
|
||||
substring(inv.notes FROM 'GRN Number: ([^\\n\\r]+)')
|
||||
) IS NOT NULL
|
||||
)`,
|
||||
[bookingIds],
|
||||
);
|
||||
if (rows.length) {
|
||||
const refs = rows.map((r) => r.reference ?? '(unknown)').join(', ');
|
||||
throw new BadRequestException(
|
||||
`These export bookings are not received at the warehouse yet — receive their cargo and generate a GRN before loading: ${refs}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private buildImportLoadListHtml(loadList: Awaited<ReturnType<TrainSchedulingService['generateImportLoadList']>>): string {
|
||||
const esc = (value: unknown) =>
|
||||
String(value ?? '-')
|
||||
|
||||
Reference in New Issue
Block a user