Merge pull request #864 from Tria-plc/testfixes

feat(export): gate receive on payment and loading on received + GRN
This commit is contained in:
Hagernesh Tadesse
2026-07-21 11:08:27 +03:00
committed by GitHub
6 changed files with 211 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource } from 'typeorm';
import { assertExportReceivedWithGrn } from './export-received-gate';
const db = (rows: unknown[]) =>
({ query: jest.fn().mockResolvedValue(rows) }) as unknown as DataSource;
describe('assertExportReceivedWithGrn', () => {
it('passes when the export booking has a received row with a GRN', async () => {
await expect(
assertExportReceivedWithGrn(db([{ '?column?': 1 }]), {
id: 'b-1',
tradeDirection: 'EXPORT',
}),
).resolves.toBeUndefined();
});
it('rejects an export booking with nothing received', async () => {
await expect(
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'EXPORT' }),
).rejects.toBeInstanceOf(BadRequestException);
});
it('never blocks import — it loads off a train, not out of the warehouse', async () => {
const source = db([]);
await expect(
assertExportReceivedWithGrn(source, { id: 'b-1', tradeDirection: 'IMPORT' }),
).resolves.toBeUndefined();
// Import short-circuits before querying.
expect((source.query as jest.Mock)).not.toHaveBeenCalled();
});
it('does not block intercity cargo', async () => {
await expect(
assertExportReceivedWithGrn(db([]), { id: 'b-1', tradeDirection: 'DOMESTIC' }),
).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,50 @@
import { BadRequestException } from '@nestjs/common';
import type { DataSource, EntityManager } from 'typeorm';
/** The booking fields the gate needs. */
export interface ExportLoadGateBooking {
id: string;
tradeDirection?: string | null;
}
/**
* Export cargo may not be loaded onto its train until it has physically reached
* the warehouse and been issued a GRN — whether it got there by first-mile or by
* the customer's own truck, and even though a wagon is already allocated. An
* allocation is a plan; the GRN is the proof the goods are actually in hand.
*
* Several loading paths (per-yard load, workspace confirm-loaded) marked cargo
* loaded straight off the allocation, skipping the warehouse, so a booking could
* ride the train with nothing ever received. This closes that for export; import
* loads off a train and is unaffected.
*
* "Received with a GRN" = an inventory row that has reached the warehouse
* (RECEIVED or any later stage) and carries a GRN, in the column or the notes
* fallback older rows use.
*/
export async function assertExportReceivedWithGrn(
db: DataSource | EntityManager,
booking: ExportLoadGateBooking,
): Promise<void> {
if (booking.tradeDirection !== 'EXPORT') return;
const [row] = await db.query(
`SELECT 1
FROM freight.warehouse_inventory inv
WHERE inv.booking_id = $1
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
LIMIT 1`,
[booking.id],
);
if (!row) {
throw new BadRequestException(
'This export booking has not been received at the warehouse yet — receive its cargo and generate a GRN before loading it onto the train.',
);
}
}

View File

@@ -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) => {

View File

@@ -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 ?? '-')

View File

@@ -0,0 +1,55 @@
import { BadRequestException } from '@nestjs/common';
import { WarehouseInventoryService } from './warehouse-inventory.service';
/**
* Export cargo is received into the warehouse to wait for its train, and only a
* paid booking may be received — otherwise storage and a GRN would start against
* cargo the customer has not settled. Import is never blocked: it arrives OFF a
* train and its receive is the unload.
*
* The guard touches only the DataSource, so the instance is built off the
* prototype rather than stubbing all 20-odd collaborators.
*/
type Guard = (
bookingId: string | null | undefined,
direction: string | null,
) => Promise<void>;
function makeGuard(paymentStatus: string | null) {
const query = jest.fn().mockResolvedValue([{ paymentStatus }]);
const service = Object.create(WarehouseInventoryService.prototype) as Record<string, unknown>;
service.dataSource = { query };
const guard = (
service as unknown as { assertExportBookingPaid: Guard }
).assertExportBookingPaid.bind(service);
return { guard, query };
}
describe('receive() — export paid gate', () => {
it('rejects an unpaid export booking', async () => {
const { guard } = makeGuard('PENDING');
await expect(guard('b-1', 'EXPORT')).rejects.toBeInstanceOf(BadRequestException);
});
it('allows a paid export booking', async () => {
const { guard } = makeGuard('PAID');
await expect(guard('b-1', 'EXPORT')).resolves.toBeUndefined();
});
it('never blocks import, paid or not', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard('b-1', 'IMPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
it('ignores a receive with no booking attached', async () => {
const { guard, query } = makeGuard('PENDING');
await expect(guard(null, 'EXPORT')).resolves.toBeUndefined();
expect(query).not.toHaveBeenCalled();
});
});

View File

@@ -2550,6 +2550,7 @@ export class WarehouseInventoryService {
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {
const bookingDirection = dto.bookingId ? await this.getBookingDirection(dto.bookingId) : null;
await this.assertExportBookingPaid(dto.bookingId, bookingDirection);
const id = await this.dataSource.transaction(async (manager) => {
const { warehouse, yard, zone } = await this.validateLocation(manager, dto);
@@ -5648,6 +5649,31 @@ export class WarehouseInventoryService {
);
}
/**
* Export cargo is received into the warehouse to wait for its train, and it is
* received only once the booking is paid — receiving an unpaid export booking
* would start storage and mint a GRN against cargo the customer has not settled.
*
* Export only: import cargo arrives OFF a train and its receive is the unload,
* so gating that on payment would strand cargo already at the yard.
*/
private async assertExportBookingPaid(
bookingId: string | null | undefined,
direction: string | null,
): Promise<void> {
if (!bookingId || direction !== 'EXPORT') return;
const [row]: Array<{ paymentStatus: string | null }> = await this.dataSource.query(
`SELECT payment_status AS "paymentStatus"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL`,
[bookingId],
);
if ((row?.paymentStatus ?? '').toUpperCase() !== 'PAID') {
throw new BadRequestException(
'This export booking is not paid yet — its cargo cannot be received at the warehouse until payment is settled.',
);
}
}
private assertCapacity(
label: string,
node: LocationNode,