feat(warehouse): route-based receive direction + Export Receive Queue (Batch 2)

- Direction derived from route via existing deriveTradeDirection (eligible-bookings,
  bulk receive guard, getBookingDirection) instead of stored trade_direction
- Export tab sub-tabs (Receive Queue + Ready-to-Load/Loaded/Dispatch placeholders)
- Receive Queue: full column set + per-row Receive; eligible-bookings adds customerId
- create/update warehouse: map DB errors to 400; dashboard: distinct per-status colors

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hagernesh
2026-06-20 10:26:50 +00:00
parent 0005b2edb3
commit b7a831b063
5 changed files with 174 additions and 63 deletions

View File

@@ -1,6 +1,7 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm';
import { deriveTradeDirection } from '../../common/derive-trade-direction.util';
import { Cargo } from '../cargoes/entities/cargoes.entity';
import { BulkReceiveDto } from './dto/bulk-receive.dto';
import { DeliverInventoryDto } from './dto/deliver-inventory.dto';
@@ -121,6 +122,7 @@ export interface AutoLoadResult {
export interface EligibleBookingRow {
id: string;
reference: string;
customerId: string | null;
customer: string | null;
direction: string;
origin: string | null;
@@ -436,15 +438,19 @@ export class WarehouseInventoryService {
// ── 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(
/** Eligible PAID bookings for a direction (DERIVED FROM ROUTE) that have NOT been received yet. */
async eligibleBookings(direction: 'IMPORT' | 'EXPORT'): Promise<EligibleBookingRow[]> {
const rows: Array<
EligibleBookingRow & { originCountry: string | null; destinationCountry: string | null }
> = await this.dataSource.query(
`SELECT b.id,
b.reference AS "reference",
b.company_id AS "customerId",
company.name AS "customer",
b.trade_direction AS "direction",
oy.code AS "origin",
dy.code AS "destination",
oy.country AS "originCountry",
dy.country AS "destinationCountry",
b.freight_type AS "freightType",
COALESCE(ct.cargo_type_name, b.cargo_free_text) AS "cargo",
b.cargo_total_weight_vgm AS "weight",
@@ -458,11 +464,17 @@ export class WarehouseInventoryService {
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],
);
// Direction is derived from the route (origin/destination yard countries), reusing deriveTradeDirection.
return rows
.map((r) => ({
...r,
direction: deriveTradeDirection({ country: r.originCountry }, { country: r.destinationCountry }),
}))
.filter((r) => r.direction === direction);
}
/** Bulk-receive eligible PAID bookings into a location. Skips duplicates / wrong direction. */
@@ -483,15 +495,23 @@ export class WarehouseInventoryService {
};
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`,
`SELECT b.payment_status AS "paymentStatus", b.cargo_total_weight_vgm AS "weight",
oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.bookings b
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
WHERE b.id = $1 AND b.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}`);
// Direction is derived from the route (yard countries), not the stored field.
const bookingDirection = deriveTradeDirection(
{ country: booking.originCountry },
{ country: booking.destinationCountry },
);
if (bookingDirection !== dto.direction) {
skip(`Booking route is ${bookingDirection}, not ${dto.direction}`);
continue;
}
@@ -1164,13 +1184,21 @@ export class WarehouseInventoryService {
return rows?.[0]?.status ?? null;
}
/** IMPORT | EXPORT | DOMESTIC for the booking, or null if the booking is missing. */
/** IMPORT | EXPORT | DOMESTIC derived from the booking ROUTE (yard countries), or null if missing. */
private async getBookingDirection(bookingId: string): Promise<string | null> {
const rows = await this.dataSource.query(
'SELECT trade_direction FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
`SELECT oy.country AS "originCountry", dy.country AS "destinationCountry"
FROM freight.bookings b
LEFT JOIN freight.yards oy ON oy.id = b.origin_yard_id
LEFT JOIN freight.yards dy ON dy.id = b.destination_yard_id
WHERE b.id = $1 AND b.deleted_at IS NULL LIMIT 1`,
[bookingId],
);
return rows?.[0]?.trade_direction ?? null;
if (!rows?.[0]) return null;
return deriveTradeDirection(
{ country: rows[0].originCountry },
{ country: rows[0].destinationCountry },
);
}
private assertCapacity(

View File

@@ -1,5 +1,5 @@
import { ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { FindManyOptions, ILike } from 'typeorm';
import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { FindManyOptions, ILike, QueryFailedError } from 'typeorm';
import { CreateWarehouseDto } from './dto/create-warehouse.dto';
import { FilterWarehouseDto } from './dto/filter-warehouse.dto';
@@ -49,23 +49,27 @@ export class WarehousesService {
async create(dto: CreateWarehouseDto): Promise<Warehouse> {
await this.assertCodeUnique(dto.code.trim());
return this.warehousesRepository.create({
name: dto.name.trim(),
code: dto.code.trim(),
type: dto.type,
stationId: dto.stationId ?? null,
facilityId: dto.facilityId ?? null,
locationName: dto.locationName?.trim() ?? null,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
maxVolume: dto.maxVolume ?? null,
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
});
try {
return await this.warehousesRepository.create({
name: dto.name.trim(),
code: dto.code.trim(),
type: dto.type,
stationId: dto.stationId ?? null,
facilityId: dto.facilityId ?? null,
locationName: dto.locationName?.trim() ?? null,
capacityWeight: dto.capacityWeight ?? null,
capacityContainers: dto.capacityContainers ?? null,
maxWeight: dto.maxWeight ?? dto.capacityWeight ?? null,
maxVolume: dto.maxVolume ?? null,
currentWeight: 0,
currentContainers: 0,
currentVolume: 0,
status: 'ACTIVE',
isActive: true,
});
} catch (error) {
this.mapDbError(error);
}
}
async update(id: string, dto: UpdateWarehouseDto): Promise<Warehouse> {
@@ -77,20 +81,25 @@ export class WarehousesService {
const status = dto.status ?? existing.status;
const updated = await this.warehousesRepository.update(id, {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
stationId: dto.stationId ?? existing.stationId,
facilityId: dto.facilityId ?? existing.facilityId,
locationName: dto.locationName?.trim() ?? existing.locationName,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
isActive: status === 'ACTIVE',
});
let updated;
try {
updated = await this.warehousesRepository.update(id, {
name: dto.name?.trim() ?? existing.name,
code: dto.code?.trim() ?? existing.code,
type: dto.type ?? existing.type,
stationId: dto.stationId ?? existing.stationId,
facilityId: dto.facilityId ?? existing.facilityId,
locationName: dto.locationName?.trim() ?? existing.locationName,
capacityWeight: dto.capacityWeight ?? existing.capacityWeight,
capacityContainers: dto.capacityContainers ?? existing.capacityContainers,
maxWeight: dto.maxWeight ?? existing.maxWeight,
maxVolume: dto.maxVolume ?? existing.maxVolume,
status,
isActive: status === 'ACTIVE',
});
} catch (error) {
this.mapDbError(error);
}
if (!updated) {
throw new NotFoundException(`Warehouse ${id} not found`);
@@ -99,6 +108,21 @@ export class WarehousesService {
return this.findById(id);
}
/** Map low-level DB errors (FK / length / etc.) to a clean 400 instead of a 500. */
private mapDbError(error: unknown): never {
if (error instanceof QueryFailedError) {
const driver = (error as QueryFailedError & { driverError?: { code?: string; detail?: string } }).driverError;
if (driver?.code === '23503') {
throw new BadRequestException('Selected facility does not exist.');
}
if (driver?.code === '22001') {
throw new BadRequestException('A field is too long (code max 40, name max 160 characters).');
}
throw new BadRequestException(driver?.detail ?? error.message ?? 'Invalid warehouse data.');
}
throw error as Error;
}
private async assertCodeUnique(code: string, ignoreId?: string): Promise<void> {
const [existing] = await this.warehousesRepository.findAll({ where: { code } });