From b7a831b0630581b45d7b95187d615d1743fee036 Mon Sep 17 00:00:00 2001 From: Hagernesh Date: Sat, 20 Jun 2026 10:26:50 +0000 Subject: [PATCH] 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) --- .../warehouses/warehouse-inventory.service.ts | 56 +++++++++--- .../modules/warehouses/warehouses.service.ts | 90 ++++++++++++------- .../warehouses/ReceiveInventoryModal.tsx | 72 +++++++++++++-- .../warehouses/WarehouseDashboardCharts.tsx | 18 ++-- .../backoffice/src/types/warehouse.ts | 1 + 5 files changed, 174 insertions(+), 63 deletions(-) diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts index bed06cda0..29685efd3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.service.ts @@ -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 { - 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 { + 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 { 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( diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts index 3cbcc6833..f92401dfc 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -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 { 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 { @@ -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 { const [existing] = await this.warehousesRepository.findAll({ where: { code } }); diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx index 8a9613e7e..353a7c42f 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/ReceiveInventoryModal.tsx @@ -237,7 +237,7 @@ function EligibleTab({ No eligible PAID {direction.toLowerCase()} bookings to receive. ) : ( - + @@ -249,14 +249,20 @@ function EligibleTab({ onChange={toggleAll} /> - Booking - Customer + Booking Ref + Booking ID + Customer ID + Customer Name Origin Destination - Freight - Cargo + Route + Container # + Cargo Type Weight Payment + Current Status + Inspection + Actions @@ -274,10 +280,19 @@ function EligibleTab({ {r.reference} + + {r.id.slice(0, 8)}… + + + {r.customerId ? `${r.customerId.slice(0, 8)}…` : '—'} + {r.customer ?? '—'} {r.origin ?? '—'} {r.destination ?? '—'} - {r.freightType ?? '—'} + + {r.origin || r.destination ? `${r.origin ?? '?'} → ${r.destination ?? '?'}` : '—'} + + {r.cargo ?? '—'} {formatNumber(Number(r.weight))} @@ -285,6 +300,23 @@ function EligibleTab({ {r.paymentStatus} + + + {r.status ?? '—'} + + + + + + ))} @@ -323,7 +355,33 @@ function BulkReceiveModal({ opened, onClose, onReceived }: ReceiveInventoryModal - + + + Receive Queue + Ready To Load + Loaded + Dispatch Queue + + + + + + + + Ready To Load — coming in the next batch. + + + + + Loaded — coming in the next batch. + + + + + Dispatch Queue — coming in the next batch. + + + diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx index 78b0cf1c8..b2a628d88 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/WarehouseDashboardCharts.tsx @@ -23,15 +23,15 @@ interface WarehouseDashboardChartsProps { } const ORANGE = '#f08c00'; -const GREEN = '#5bbf4a'; +const GREEN = '#22c55e'; // green from bookings -/** Inventory lifecycle status series — alternating orange / light green. */ +/** Inventory lifecycle status series — one distinct color per status (aligned with status badges). */ const STATUS_SERIES = [ - { key: 'stored', label: 'Stored', color: ORANGE }, - { key: 'reserved', label: 'Reserved', color: GREEN }, - { key: 'readyForLoading', label: 'Ready', color: ORANGE }, - { key: 'loaded', label: 'Loaded', color: GREEN }, - { key: 'dispatched', label: 'Dispatched', color: ORANGE }, + { key: 'stored', label: 'Stored', color: '#228be6' }, // blue + { key: 'reserved', label: 'Reserved', color: '#ae3ec9' }, // grape + { key: 'readyForLoading', label: 'Ready', color: '#f08c00' }, // orange + { key: 'loaded', label: 'Loaded', color: '#12b886' }, // teal + { key: 'dispatched', label: 'Dispatched', color: GREEN }, // green (bookings) ] as const; type Granularity = 'week' | 'month' | 'year'; @@ -157,8 +157,8 @@ export function WarehouseDashboardCharts({ data }: WarehouseDashboardChartsProps outerRadius={95} paddingAngle={2} > - {statusData.map((entry, i) => ( - + {statusData.map((entry) => ( + ))} diff --git a/apps/edr-freight-web/backoffice/src/types/warehouse.ts b/apps/edr-freight-web/backoffice/src/types/warehouse.ts index 10436b3c8..86f315ce5 100644 --- a/apps/edr-freight-web/backoffice/src/types/warehouse.ts +++ b/apps/edr-freight-web/backoffice/src/types/warehouse.ts @@ -336,6 +336,7 @@ export interface DeliverInventoryPayload { export interface EligibleBooking { id: string; reference: string; + customerId: string | null; customer: string | null; direction: string; origin: string | null;