mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 09:30:59 +00:00
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:
@@ -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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user