feat(warehouse): Receive Import/Export tabs with bulk receive + load-passed-export

- Backend: GET eligible-bookings?direction, POST receive-bulk, POST load-passed-export
  (reuse autoUnload/autoLoad patterns; no train-schedule/wagon logic changed)
- Frontend: ReceiveInventoryModal split into Import/Export tabs with eligible PAID
  bookings table, select-all/bulk receive, and Load Passed Export Items button
- Single-booking receive and all existing inventory row actions preserved

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-20 08:29:59 +00:00
parent d7352ba4e8
commit 0005b2edb3
8 changed files with 632 additions and 88 deletions

View File

@@ -0,0 +1,32 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { ArrayNotEmpty, IsArray, IsIn, IsOptional, IsString, IsUUID } from 'class-validator';
/** Bulk-receive eligible PAID bookings into a warehouse location, by trade direction. */
export class BulkReceiveDto {
@ApiProperty({ enum: ['IMPORT', 'EXPORT'] })
@IsIn(['IMPORT', 'EXPORT'])
direction!: 'IMPORT' | 'EXPORT';
@ApiProperty({ format: 'uuid' })
@IsUUID()
warehouseId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
yardId!: string;
@ApiProperty({ format: 'uuid' })
@IsUUID()
zoneId!: string;
@ApiProperty({ type: [String], format: 'uuid' })
@IsArray()
@ArrayNotEmpty()
@IsUUID('all', { each: true })
bookingIds!: string[];
@ApiPropertyOptional()
@IsOptional()
@IsString()
performedBy?: string;
}

View File

@@ -1,6 +1,7 @@
import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
@@ -58,6 +59,24 @@ export class WarehouseInventoryController {
return this.inventoryService.autoLoadReady();
}
@Get('eligible-bookings')
@ApiOperation({ summary: 'Eligible PAID bookings for a direction (IMPORT/EXPORT) not yet received' })
eligibleBookings(@Query('direction') direction: 'IMPORT' | 'EXPORT') {
return this.inventoryService.eligibleBookings(direction === 'EXPORT' ? 'EXPORT' : 'IMPORT');
}
@Post('receive-bulk')
@ApiOperation({ summary: 'Bulk-receive selected eligible PAID bookings into a location' })
receiveBulk(@Body() dto: BulkReceiveDto) {
return this.inventoryService.bulkReceive(dto);
}
@Post('load-passed-export')
@ApiOperation({ summary: 'Bulk-load all EXPORT inventory that passed inspection (READY_FOR_LOADING)' })
loadPassedExport(@Body('performedBy') performedBy?: string) {
return this.inventoryService.loadPassedExport(performedBy);
}
@Post('bookings/:bookingId/unload')
@ApiOperation({ summary: 'Unload a single arrived booking into a location' })
unloadBooking(

View File

@@ -2,6 +2,7 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
@@ -116,6 +117,33 @@ export interface AutoLoadResult {
results: { inventoryId: string; status: string; reason?: string }[];
}
// ── Receive (Import/Export bulk) shapes ──────────────────────────────────────
export interface EligibleBookingRow {
id: string;
reference: string;
customer: string | null;
direction: string;
origin: string | null;
destination: string | null;
freightType: string | null;
cargo: string | null;
weight: string | null;
paymentStatus: string;
status: string;
}
export interface BulkReceiveResult {
receivedCount: number;
skippedCount: number;
results: { bookingId: string; status: string; inventoryId?: string; reason?: string }[];
}
export interface LoadPassedExportResult {
loadedCount: number;
skippedCount: number;
results: { inventoryId: string; status: string; reason?: string }[];
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -406,6 +434,144 @@ export class WarehouseInventoryService {
return result;
}
// ── Receive (Import/Export bulk) ───────────────────────────────────────────
/** Eligible PAID bookings for a direction that have NOT been received yet. */
eligibleBookings(direction: 'IMPORT' | 'EXPORT'): Promise<EligibleBookingRow[]> {
return this.dataSource.query(
`SELECT b.id,
b.reference AS "reference",
company.name AS "customer",
b.trade_direction AS "direction",
oy.code AS "origin",
dy.code AS "destination",
b.freight_type AS "freightType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
b.cargo_total_weight_vgm AS "weight",
b.payment_status AS "paymentStatus",
b.status AS "status"
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
LEFT JOIN freight.cargo_types ct ON ct.id = b.cargo_type_id
LEFT JOIN freight.warehouse_inventory inv ON inv.booking_id = b.id AND inv.deleted_at IS NULL
WHERE b.deleted_at IS NULL
AND b.payment_status = 'PAID'
AND b.trade_direction = $1
AND inv.id IS NULL
ORDER BY b.scheduled_date DESC NULLS LAST`,
[direction],
);
}
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
async bulkReceive(dto: BulkReceiveDto): Promise<BulkReceiveResult> {
const result: BulkReceiveResult = { receivedCount: 0, skippedCount: 0, results: [] };
await this.dataSource.transaction(async (manager) => {
await this.validateLocation(manager, {
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
});
for (const bookingId of dto.bookingIds) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ bookingId, status: 'SKIPPED', reason });
};
const [booking] = await manager.query(
`SELECT payment_status AS "paymentStatus", trade_direction AS "tradeDirection",
cargo_total_weight_vgm AS "weight"
FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1`,
[bookingId],
);
if (!booking) { skip('Booking not found'); continue; }
if (booking.paymentStatus !== 'PAID') { skip('Booking not PAID'); continue; }
if (booking.tradeDirection !== dto.direction) {
skip(`Booking is ${booking.tradeDirection}, not ${dto.direction}`);
continue;
}
const existing = await manager.getRepository(WarehouseInventory).findOne({ where: { bookingId } });
if (existing) { skip('Already received'); continue; }
const saved = await manager.getRepository(WarehouseInventory).save(
manager.getRepository(WarehouseInventory).create({
warehouseId: dto.warehouseId,
yardId: dto.yardId,
zoneId: dto.zoneId,
bookingId,
quantity: 1,
weight: Number(booking.weight) || 0,
status: 'RECEIVED',
arrivedAt: new Date(),
notes: `Bulk received (${dto.direction})`,
}),
);
await this.activityLog.record(
{
activityType: 'INVENTORY_RECEIVED',
inventoryId: saved.id,
warehouseId: dto.warehouseId,
description: `Bulk received ${dto.direction} booking`,
performedBy: dto.performedBy,
},
manager,
);
result.receivedCount += 1;
result.results.push({ bookingId, status: 'RECEIVED', inventoryId: saved.id });
}
});
return result;
}
/** Bulk-load all EXPORT inventory that passed inspection and is READY_FOR_LOADING. */
async loadPassedExport(performedBy?: string): Promise<LoadPassedExportResult> {
const ready = await this.inventoryRepository.findAll({ where: { status: 'READY_FOR_LOADING' } });
const result: LoadPassedExportResult = { loadedCount: 0, skippedCount: 0, results: [] };
for (const item of ready) {
const skip = (reason: string) => {
result.skippedCount += 1;
result.results.push({ inventoryId: item.id, status: 'SKIPPED', reason });
};
if (item.inspectionStatus !== 'PASSED') { skip('Inspection not PASSED'); continue; }
const direction = item.bookingId ? await this.getBookingDirection(item.bookingId) : null;
if (direction !== 'EXPORT') { skip('Not an EXPORT item'); continue; }
const bookingStatus = item.bookingId ? await this.getBookingStatus(item.bookingId) : null;
if (bookingStatus !== 'PAID') { skip('Booking not PAID'); continue; }
await this.dataSource.transaction(async (manager) => {
await manager.getRepository(WarehouseInventory).update(item.id, {
status: 'LOADED',
loadedAt: new Date(),
});
await this.activityLog.record(
{
activityType: 'INVENTORY_LOADED',
inventoryId: item.id,
warehouseId: item.warehouseId,
description: 'Bulk loaded (passed export)',
performedBy,
},
manager,
);
});
result.loadedCount += 1;
result.results.push({ inventoryId: item.id, status: 'LOADED' });
}
return result;
}
// ── Receive ──────────────────────────────────────────────────────────────
async receive(dto: ReceiveWarehouseInventoryDto): Promise<WarehouseInventory> {