feat(warehouse): batch 3 — loading, dispatch & train-departure visibility

- WarehouseLoading entity + Batch3 migration (wagon loading records)
- wagon-aware load() with validation; dispatch moved to PATCH
- read-only SchedulingReadFacade (schedule/wagon/departure) — never writes scheduling
- new endpoints: GET /warehouse-loadings, loadable-wagons, booking schedule
- Loading Queue / Loaded Inventory / Dispatch Queue pages + routes + sidebar
- FreightVisual illustrations (page heroes + empty states)
- booking detail: loaded/dispatched/wagon + read-only train schedule

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-12 12:44:40 +00:00
parent 72e2e3c985
commit 95c6a71438
27 changed files with 1193 additions and 30 deletions

View File

@@ -0,0 +1,25 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsNumber, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
export class LoadInventoryDto {
@ApiProperty({ format: 'uuid', description: 'Physical wagon the item is loaded onto' })
@IsUUID()
wagonId!: string;
@ApiPropertyOptional({ description: 'Weight loaded onto the wagon (kg)' })
@IsOptional()
@IsNumber()
@Min(0)
loadedWeight?: number;
@ApiPropertyOptional()
@IsOptional()
@IsString()
@MaxLength(120)
loadedBy?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}

View File

@@ -0,0 +1,46 @@
import { BaseEntity } from '@edr/api-common';
import { Column, Entity, Index, JoinColumn, ManyToOne } from 'typeorm';
import { Booking } from '../../bookings/entities/booking.entity';
import { WarehouseInventory } from './warehouse-inventory.entity';
/**
* Batch 3 — a record that a warehouse inventory item was physically loaded onto a wagon.
* The warehouse OWNS this record. It only READS wagon/schedule data from the scheduling
* domain (via SchedulingReadFacade); it never writes to wagons or train schedules.
*/
@Entity({ schema: 'freight', name: 'warehouse_loadings' })
@Index(['warehouseInventoryId'])
@Index(['bookingId'])
@Index(['wagonId'])
export class WarehouseLoading extends BaseEntity {
@Column({ name: 'warehouse_inventory_id', type: 'uuid' })
warehouseInventoryId!: string;
@ManyToOne(() => WarehouseInventory)
@JoinColumn({ name: 'warehouse_inventory_id' })
inventory?: WarehouseInventory;
@Column({ name: 'booking_id', type: 'uuid', nullable: true })
bookingId?: string | null;
@ManyToOne(() => Booking, { nullable: true })
@JoinColumn({ name: 'booking_id' })
booking?: Booking | null;
/** Physical wagon the item was loaded onto. References freight.wagons (read-only link). */
@Column({ name: 'wagon_id', type: 'uuid' })
wagonId!: string;
@Column({ name: 'loaded_at', type: 'timestamptz' })
loadedAt!: Date;
@Column({ name: 'loaded_by', type: 'varchar', length: 120, nullable: true })
loadedBy?: string | null;
@Column({ name: 'loaded_weight', type: 'numeric', precision: 14, scale: 3, nullable: true })
loadedWeight?: number | null;
@Column({ name: 'notes', type: 'text', nullable: true })
notes?: string | null;
}

View File

@@ -0,0 +1,118 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
/**
* READ-ONLY view into the train-scheduling / wagons domain for the warehouse module.
*
* IMPORTANT: this facade only ever runs SELECTs. The warehouse must never modify
* wagon assignment, rescheduling, import_ready/export_ready, or locomotive flow.
* It is intentionally decoupled (raw SQL) so it does not import the scheduling
* services/entities and cannot accidentally write to them.
*/
export interface WagonView {
id: string;
wagonNumber: string;
status: string;
trainId: string | null;
}
export interface BookingScheduleView {
schedule: {
id: string;
status: string;
scheduledDepartureDate: string | null;
scheduledArrivalDate: string | null;
originStationId: string | null;
destinationStationId: string | null;
} | null;
wagon: {
wagonId: string | null;
wagonNumber: string | null;
sequenceNo: number | null;
allocatedWeightTons: number | null;
} | null;
/** Mirror of schedule.status — the headline "where is the train" indicator. */
departureStatus: string | null;
}
@Injectable()
export class SchedulingReadFacade {
constructor(private readonly dataSource: DataSource) {}
/** Look up a single physical wagon. Returns null if it does not exist. */
async findWagon(wagonId: string): Promise<WagonView | null> {
const rows = await this.dataSource.query(
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
FROM freight.wagons
WHERE id = $1 AND deleted_at IS NULL
LIMIT 1`,
[wagonId],
);
return rows?.[0] ?? null;
}
/** True when the wagon is already part of a train set (selected by an existing schedule). */
async isWagonScheduled(wagonId: string): Promise<boolean> {
const rows = await this.dataSource.query(
`SELECT 1 FROM freight.train_set_wagons
WHERE physical_wagon_id = $1 AND deleted_at IS NULL
LIMIT 1`,
[wagonId],
);
return (rows?.length ?? 0) > 0;
}
/** List wagons usable for loading (available, or already assigned to a schedule). */
listLoadableWagons(): Promise<WagonView[]> {
return this.dataSource.query(
`SELECT id, wagon_number AS "wagonNumber", status, train_id AS "trainId"
FROM freight.wagons
WHERE deleted_at IS NULL
AND status NOT IN ('RETIRED', 'MAINTENANCE')
ORDER BY wagon_number ASC`,
);
}
/**
* Given a booking, return its related schedule, wagon assignment and departure status.
* All fields are read straight from the scheduling tables — nothing is written.
*/
async getBookingSchedule(bookingId: string): Promise<BookingScheduleView> {
const scheduleRows = await this.dataSource.query(
`SELECT ts.id,
ts.status,
ts.scheduled_departure_date AS "scheduledDepartureDate",
ts.scheduled_arrival_date AS "scheduledArrivalDate",
ts.origin_station_id AS "originStationId",
ts.destination_station_id AS "destinationStationId"
FROM freight.train_schedule_bookings tsb
INNER JOIN freight.train_schedules ts ON ts.id = tsb.train_schedule_id
WHERE tsb.booking_id = $1 AND ts.deleted_at IS NULL
ORDER BY ts.scheduled_departure_date DESC NULLS LAST
LIMIT 1`,
[bookingId],
);
const schedule = scheduleRows?.[0] ?? null;
const wagonRows = await this.dataSource.query(
`SELECT w.id AS "wagonId",
w.wagon_number AS "wagonNumber",
tsw.sequence_no AS "sequenceNo",
wba.allocated_weight_tons AS "allocatedWeightTons"
FROM freight.wagon_booking_allocations wba
INNER JOIN freight.train_set_wagons tsw ON tsw.id = wba.train_set_wagon_id
LEFT JOIN freight.wagons w ON w.id = tsw.physical_wagon_id
WHERE wba.booking_id = $1
ORDER BY tsw.sequence_no ASC NULLS LAST
LIMIT 1`,
[bookingId],
);
const wagon = wagonRows?.[0] ?? null;
return {
schedule,
wagon,
departureStatus: schedule?.status ?? null,
};
}
}

View File

@@ -1,18 +1,23 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Post, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseInventoryService } from './warehouse-inventory.service';
@ApiTags('warehouse-inventory')
@ApiBearerAuth()
@Controller('warehouse-inventory')
export class WarehouseInventoryController {
constructor(private readonly inventoryService: WarehouseInventoryService) {}
constructor(
private readonly inventoryService: WarehouseInventoryService,
private readonly scheduling: SchedulingReadFacade,
) {}
@Get()
@ApiOperation({ summary: 'List warehouse inventory' })
@@ -32,6 +37,18 @@ export class WarehouseInventoryController {
return this.inventoryService.inquiry(filter);
}
@Get('loadable-wagons')
@ApiOperation({ summary: 'List wagons usable for loading (read-only from scheduling)' })
loadableWagons() {
return this.scheduling.listLoadableWagons();
}
@Get('booking/:bookingId/schedule')
@ApiOperation({ summary: 'Read-only schedule + wagon + departure status for a booking' })
bookingSchedule(@Param('bookingId', ParseUUIDPipe) bookingId: string) {
return this.scheduling.getBookingSchedule(bookingId);
}
@Post('receive')
@ApiOperation({ summary: 'Receive inventory at a warehouse location' })
receive(@Body() dto: ReceiveWarehouseInventoryDto) {
@@ -56,6 +73,12 @@ export class WarehouseInventoryController {
return this.inventoryService.findActivity(id);
}
@Get(':id/loadings')
@ApiOperation({ summary: 'Loading records for an inventory item' })
loadings(@Param('id', ParseUUIDPipe) id: string) {
return this.inventoryService.findLoadingsByInventory(id);
}
@Post(':id/move')
@ApiOperation({ summary: 'Move inventory to another warehouse/yard/zone' })
move(@Param('id', ParseUUIDPipe) id: string, @Body() dto: MoveInventoryDto) {
@@ -75,13 +98,13 @@ export class WarehouseInventoryController {
}
@Post(':id/load')
@ApiOperation({ summary: 'Mark inventory LOADED' })
load(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.load(id, performedBy);
@ApiOperation({ summary: 'Load READY_FOR_LOADING inventory onto a wagon' })
load(@Param('id', ParseUUIDPipe) id: string, @Body() dto: LoadInventoryDto) {
return this.inventoryService.load(id, dto);
}
@Post(':id/dispatch')
@ApiOperation({ summary: 'Mark inventory DISPATCHED' })
@Patch(':id/dispatch')
@ApiOperation({ summary: 'Mark loaded inventory DISPATCHED (left the terminal)' })
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy);
}

View File

@@ -3,6 +3,7 @@ import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
@@ -13,11 +14,17 @@ import {
WarehouseInventory,
WarehouseInventoryStatus,
} from './entities/warehouse-inventory.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
import { WarehouseZone } from './entities/warehouse-zone.entity';
import { Warehouse } from './entities/warehouse.entity';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
/** Wagon states that may receive a load (besides being part of an existing schedule). */
const LOADABLE_WAGON_STATUSES = ['AVAILABLE', 'IMPORT_READY', 'EXPORT_READY', 'ASSIGNED'];
export interface InventoryInquiryResult {
id: string;
@@ -53,7 +60,9 @@ export class WarehouseInventoryService {
constructor(
private readonly dataSource: DataSource,
private readonly inventoryRepository: WarehouseInventoryRepository,
private readonly loadingRepository: WarehouseLoadingRepository,
private readonly activityLog: WarehouseActivityLogService,
private readonly scheduling: SchedulingReadFacade,
) {}
// ── Listing ────────────────────────────────────────────────────────────
@@ -215,12 +224,112 @@ export class WarehouseInventoryService {
});
}
load(id: string, performedBy?: string): Promise<WarehouseInventory> {
return this.transition(id, 'LOADED', {
timestampField: 'loadedAt',
activityType: 'INVENTORY_LOADED',
description: 'Inventory loaded',
performedBy,
/**
* Load READY_FOR_LOADING inventory onto a wagon. Creates a WarehouseLoading record.
* Reads wagon/schedule data read-only — never modifies scheduling.
*/
async load(id: string, dto: LoadInventoryDto): Promise<WarehouseInventory> {
const item = await this.findById(id);
// 1. inventory status must be READY_FOR_LOADING (and not already LOADED).
this.assertTransition(item.status, 'LOADED');
// 2. inventory is at a valid warehouse/yard/zone location.
if (!item.warehouseId || !item.yardId || !item.zoneId) {
throw new BadRequestException('Inventory must be at a warehouse/yard/zone before loading');
}
// 3. wagon must exist.
const wagon = await this.scheduling.findWagon(dto.wagonId);
if (!wagon) {
throw new NotFoundException(`Wagon ${dto.wagonId} not found`);
}
// 4. wagon must be available, or already selected by an existing train schedule.
const scheduled = await this.scheduling.isWagonScheduled(dto.wagonId);
if (!LOADABLE_WAGON_STATUSES.includes(wagon.status) && !scheduled) {
throw new BadRequestException(
`Wagon ${wagon.wagonNumber} is not available for loading (status: ${wagon.status})`,
);
}
// 5. inventory must not already have a loading record.
const existing = await this.loadingRepository.findAll({ where: { warehouseInventoryId: id } });
if (existing.length > 0) {
throw new BadRequestException('Inventory has already been loaded');
}
const loadedWeight = dto.loadedWeight ?? (Number(item.weight) || 0);
await this.dataSource.transaction(async (manager) => {
const now = new Date();
await manager.getRepository(WarehouseInventory).update(id, {
status: 'LOADED',
loadedAt: now,
});
await manager.getRepository(WarehouseLoading).save(
manager.getRepository(WarehouseLoading).create({
warehouseInventoryId: id,
bookingId: item.bookingId ?? null,
wagonId: dto.wagonId,
loadedAt: now,
loadedBy: dto.loadedBy ?? null,
loadedWeight,
notes: dto.notes?.trim() ?? null,
}),
);
await this.activityLog.record(
{
activityType: 'INVENTORY_LOADED',
inventoryId: id,
warehouseId: item.warehouseId,
description: `Loaded onto wagon ${wagon.wagonNumber}`,
performedBy: dto.loadedBy,
},
manager,
);
});
return this.findById(id);
}
// ── Loading records (Batch 3) ─────────────────────────────────────────────
async findLoadings(
filter: { bookingId?: string; wagonId?: string },
): Promise<Array<WarehouseLoading & { wagonNumber: string | null }>> {
const where = {
...(filter.bookingId ? { bookingId: filter.bookingId } : {}),
...(filter.wagonId ? { wagonId: filter.wagonId } : {}),
};
const loadings = await this.loadingRepository.findAll({
where,
relations: { inventory: { warehouse: true, yard: true, zone: true } },
order: { loadedAt: 'DESC' },
});
// Enrich with wagon numbers (read-only lookup into the scheduling domain).
const wagonIds = [...new Set(loadings.map((l) => l.wagonId))];
const wagonNumbers = new Map<string, string>();
if (wagonIds.length > 0) {
const rows: Array<{ id: string; wagon_number: string }> = await this.dataSource.query(
'SELECT id, wagon_number FROM freight.wagons WHERE id = ANY($1)',
[wagonIds],
);
rows.forEach((r) => wagonNumbers.set(r.id, r.wagon_number));
}
return loadings.map((loading) =>
Object.assign(loading, { wagonNumber: wagonNumbers.get(loading.wagonId) ?? null }),
);
}
findLoadingsByInventory(inventoryId: string): Promise<WarehouseLoading[]> {
return this.loadingRepository.findAll({
where: { warehouseInventoryId: inventoryId },
order: { loadedAt: 'DESC' },
});
}

View File

@@ -0,0 +1,13 @@
import { BaseRepository } from '@edr/api-common';
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WarehouseLoading } from './entities/warehouse-loading.entity';
@Injectable()
export class WarehouseLoadingRepository extends BaseRepository<WarehouseLoading> {
constructor(@InjectRepository(WarehouseLoading) repository: Repository<WarehouseLoading>) {
super(repository);
}
}

View File

@@ -0,0 +1,17 @@
import { Controller, Get, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { WarehouseInventoryService } from './warehouse-inventory.service';
@ApiTags('warehouse-loadings')
@ApiBearerAuth()
@Controller('warehouse-loadings')
export class WarehouseLoadingsController {
constructor(private readonly inventoryService: WarehouseInventoryService) {}
@Get()
@ApiOperation({ summary: 'List wagon loading records' })
findAll(@Query('bookingId') bookingId?: string, @Query('wagonId') wagonId?: string) {
return this.inventoryService.findLoadings({ bookingId, wagonId });
}
}

View File

@@ -4,9 +4,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { WarehouseActivityLog } from './entities/warehouse-activity-log.entity';
import { WarehouseInventory } from './entities/warehouse-inventory.entity';
import { WarehouseInventoryMovement } from './entities/warehouse-inventory-movement.entity';
import { WarehouseLoading } from './entities/warehouse-loading.entity';
import { WarehouseYard } from './entities/warehouse-yard.entity';
import { WarehouseZone } from './entities/warehouse-zone.entity';
import { Warehouse } from './entities/warehouse.entity';
import { SchedulingReadFacade } from './scheduling-read.facade';
import { WarehouseActivityLogRepository } from './warehouse-activity-log.repository';
import { WarehouseActivityLogService } from './warehouse-activity-log.service';
import { WarehouseDashboardService } from './warehouse-dashboard.service';
@@ -14,6 +16,8 @@ import { WarehouseInventoryController } from './warehouse-inventory.controller';
import { WarehouseInventoryMovementRepository } from './warehouse-inventory-movement.repository';
import { WarehouseInventoryRepository } from './warehouse-inventory.repository';
import { WarehouseInventoryService } from './warehouse-inventory.service';
import { WarehouseLoadingRepository } from './warehouse-loading.repository';
import { WarehouseLoadingsController } from './warehouse-loadings.controller';
import { WarehouseSchedulingAdapterService } from './warehouse-scheduling-adapter.service';
import { WarehouseYardsController } from './warehouse-yards.controller';
import { WarehouseYardsRepository } from './warehouse-yards.repository';
@@ -34,6 +38,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInventory,
WarehouseInventoryMovement,
WarehouseActivityLog,
WarehouseLoading,
]),
],
controllers: [
@@ -41,6 +46,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseYardsController,
WarehouseZonesController,
WarehouseInventoryController,
WarehouseLoadingsController,
],
providers: [
WarehousesRepository,
@@ -49,6 +55,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseInventoryRepository,
WarehouseInventoryMovementRepository,
WarehouseActivityLogRepository,
WarehouseLoadingRepository,
WarehousesService,
WarehouseYardsService,
WarehouseZonesService,
@@ -56,6 +63,7 @@ import { WarehousesService } from './warehouses.service';
WarehouseActivityLogService,
WarehouseDashboardService,
WarehouseSchedulingAdapterService,
SchedulingReadFacade,
],
exports: [
WarehousesService,