From 484ac187a9c5fc7bda6eda6c240842d6c56c263c Mon Sep 17 00:00:00 2001 From: hagiye Date: Fri, 19 Jun 2026 17:13:17 +0300 Subject: [PATCH] warehouse --- .../warehouses/dto/filter-inventory.dto.ts | 15 ++ .../entities/warehouse-inventory.entity.ts | 5 + .../warehouses/entities/warehouse.entity.ts | 7 +- .../warehouse-inventory.controller.ts | 30 +++ .../warehouses/warehouse-inventory.service.ts | 165 ++++++++++++- .../modules/warehouses/warehouses.module.ts | 3 +- .../modules/warehouses/warehouses.service.ts | 3 +- apps/edr-freight-web/backoffice/src/App.tsx | 30 ++- .../src/components/layout/route-meta.ts | 28 +++ .../warehouses/CreateWarehouseModal.tsx | 22 +- .../warehouses/WarehouseCardView.tsx | 6 + .../warehouses/WarehouseInventoryTable.tsx | 101 ++++++-- .../components/warehouses/WarehouseTable.tsx | 6 + .../src/components/warehouses/badges.tsx | 5 + .../backoffice/src/constants/URLS.ts | 5 + .../backoffice/src/hooks/useWarehouses.ts | 68 +++++- .../warehouses/WarehouseDashboardPage.tsx | 230 ++++++++++++++++++ .../pages/warehouses/WarehouseDetailPage.tsx | 7 +- .../warehouses/WarehouseInventoryPage.tsx | 156 +++++++++++- .../pages/warehouses/WarehouseListPage.tsx | 2 +- .../src/services/warehouse.service.ts | 16 ++ .../backoffice/src/types/warehouse.ts | 39 +++ 22 files changed, 904 insertions(+), 45 deletions(-) create mode 100644 apps/edr-freight-web/backoffice/src/pages/warehouses/WarehouseDashboardPage.tsx diff --git a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts index c867eec3c..9f89e7c2c 100644 --- a/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts +++ b/apps/edr-freight-api/src/modules/warehouses/dto/filter-inventory.dto.ts @@ -12,6 +12,11 @@ export class FilterWarehouseInventoryDto { @IsUUID() warehouseId?: string; + @ApiPropertyOptional({ format: 'uuid' }) + @IsOptional() + @IsUUID() + facilityId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() @@ -51,4 +56,14 @@ export class FilterWarehouseInventoryDto { @IsOptional() @IsString() search?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + dateFrom?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + dateTo?: string; } diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts index 7cdb99aeb..e2ea4abc0 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse-inventory.entity.ts @@ -6,9 +6,14 @@ import { WarehouseYard } from './warehouse-yard.entity'; import { WarehouseZone } from './warehouse-zone.entity'; export const WAREHOUSE_INVENTORY_STATUSES = [ + 'RECEIVED', + 'STORED', + 'RESERVED', 'ARRIVED_AT_WAREHOUSE', 'UNDER_INSPECTION', 'READY_FOR_LOADING', + 'LOADED', + 'DISPATCHED', ] as const; export type WarehouseInventoryStatus = (typeof WAREHOUSE_INVENTORY_STATUSES)[number]; diff --git a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts index ee04f77e9..187030371 100644 --- a/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts +++ b/apps/edr-freight-api/src/modules/warehouses/entities/warehouse.entity.ts @@ -1,6 +1,7 @@ import { BaseEntity } from '@edr/api-common'; -import { Column, Entity, Index, OneToMany } from 'typeorm'; +import { Column, Entity, Index, JoinColumn, ManyToOne, OneToMany } from 'typeorm'; +import { Yard } from '../../rule-engine/entities/yard.entity'; import { WarehouseYard } from './warehouse-yard.entity'; export const WAREHOUSE_TYPES = ['OPEN_WAREHOUSE', 'CLOSED_WAREHOUSE'] as const; @@ -27,6 +28,10 @@ export class Warehouse extends BaseEntity { @Column({ name: 'station_id', type: 'uuid', nullable: true }) stationId?: string | null; + @ManyToOne(() => Yard, { nullable: true }) + @JoinColumn({ name: 'station_id' }) + facility?: Yard | null; + @Column({ name: 'location_name', type: 'varchar', length: 200, nullable: true }) locationName?: string | null; diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts index d357c8e0c..e91c382d3 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouse-inventory.controller.ts @@ -43,6 +43,24 @@ export class WarehouseInventoryController { return this.inventoryService.move(id, dto); } + @Get('dashboard/summary') + @ApiOperation({ summary: 'Warehouse dashboard summary' }) + dashboardSummary(@Query() filter: FilterWarehouseInventoryDto) { + return this.inventoryService.dashboardSummary(filter); + } + + @Post(':id/store') + @ApiOperation({ summary: 'Store received inventory' }) + store(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.store(id); + } + + @Post(':id/reserve') + @ApiOperation({ summary: 'Reserve stored inventory against a paid booking' }) + reserve(@Param('id', ParseUUIDPipe) id: string, @Body() dto: { bookingId?: string }) { + return this.inventoryService.reserve(id, dto); + } + @Patch(':id/inspect') @ApiOperation({ summary: 'Move inventory to UNDER_INSPECTION' }) inspect(@Param('id', ParseUUIDPipe) id: string) { @@ -54,4 +72,16 @@ export class WarehouseInventoryController { readyForLoading(@Param('id', ParseUUIDPipe) id: string) { return this.inventoryService.readyForLoading(id); } + + @Post(':id/load') + @ApiOperation({ summary: 'Mark ready inventory as loaded' }) + load(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.load(id); + } + + @Post(':id/dispatch') + @ApiOperation({ summary: 'Dispatch loaded inventory' }) + dispatch(@Param('id', ParseUUIDPipe) id: string) { + return this.inventoryService.dispatch(id); + } } 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 a80894ca8..9c6585327 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,5 +1,5 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; -import { DataSource, EntityManager, FindManyOptions, ILike } from 'typeorm'; +import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto'; import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto'; @@ -30,6 +30,17 @@ export interface InventoryInquiryResult { readyForLoadingAt: Date | null; } +export interface WarehouseDashboardSummary { + totalWarehouses: number; + totalInventory: number; + receivedToday: number; + stored: number; + reserved: number; + readyForLoading: number; + loaded: number; + dispatched: number; +} + @Injectable() export class WarehouseInventoryService { constructor( @@ -39,7 +50,16 @@ export class WarehouseInventoryService { // ── Listing ──────────────────────────────────────────────────────────── - findAll(filter: FilterWarehouseInventoryDto): Promise { + async findAll(filter: FilterWarehouseInventoryDto): Promise { + const createdAt = + filter.dateFrom && filter.dateTo + ? Between(new Date(filter.dateFrom), new Date(filter.dateTo)) + : filter.dateFrom + ? MoreThanOrEqual(new Date(filter.dateFrom)) + : filter.dateTo + ? LessThanOrEqual(new Date(filter.dateTo)) + : undefined; + const base = { ...(filter.warehouseId ? { warehouseId: filter.warehouseId } : {}), ...(filter.yardId ? { yardId: filter.yardId } : {}), @@ -49,6 +69,8 @@ export class WarehouseInventoryService { ...(filter.containerId ? { containerId: filter.containerId } : {}), ...(filter.goodsId ? { goodsId: filter.goodsId } : {}), ...(filter.status ? { status: filter.status } : {}), + ...(createdAt ? { createdAt } : {}), + ...(filter.facilityId ? { warehouse: { stationId: filter.facilityId } } : {}), }; const search = filter.search?.trim(); @@ -56,11 +78,13 @@ export class WarehouseInventoryService { ? { ...base, notes: ILike(`%${search}%`) } : base; - return this.inventoryRepository.findAll({ + const items = await this.inventoryRepository.findAll({ where, - relations: { warehouse: true, yard: true, zone: true }, + relations: { warehouse: { facility: true }, yard: true, zone: true }, order: { createdAt: 'DESC' }, }); + await this.attachBookingSummaries(items); + return items; } findReadyForLoading(filter: FilterWarehouseInventoryDto): Promise { @@ -69,7 +93,7 @@ export class WarehouseInventoryService { async findById(id: string): Promise { const item = await this.inventoryRepository.findById(id, { - relations: { warehouse: true, yard: true, zone: true }, + relations: { warehouse: { facility: true }, yard: true, zone: true }, }); if (!item) { @@ -107,7 +131,7 @@ export class WarehouseInventoryService { quantity: Number(dto.quantity) || 0, weight, volume: dto.volume ?? null, - status: 'ARRIVED_AT_WAREHOUSE', + status: 'RECEIVED', arrivedAt: now, notes: dto.notes?.trim() ?? null, }), @@ -200,12 +224,40 @@ export class WarehouseInventoryService { return this.findById(id); } + async store(id: string): Promise { + const item = await this.findById(id); + + if (item.status !== 'RECEIVED' && item.status !== 'ARRIVED_AT_WAREHOUSE') { + throw new BadRequestException(`Only RECEIVED inventory can be stored (current: ${item.status})`); + } + + await this.inventoryRepository.update(id, { status: 'STORED' }); + return this.findById(id); + } + + async reserve(id: string, dto: { bookingId?: string }): Promise { + const item = await this.findById(id); + + if (item.status !== 'STORED') { + throw new BadRequestException('Only STORED inventory can be reserved.'); + } + + const bookingId = dto.bookingId ?? item.bookingId; + await this.assertPaidBooking(this.dataSource.manager, bookingId); + + await this.inventoryRepository.update(id, { + bookingId, + status: 'RESERVED', + }); + return this.findById(id); + } + async readyForLoading(id: string): Promise { const item = await this.findById(id); - if (item.status !== 'UNDER_INSPECTION') { + if (item.status !== 'RESERVED' && item.status !== 'UNDER_INSPECTION') { throw new BadRequestException( - `Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`, + `Only RESERVED inventory can be marked READY_FOR_LOADING (current: ${item.status})`, ); } @@ -217,6 +269,64 @@ export class WarehouseInventoryService { return this.findById(id); } + async load(id: string): Promise { + const item = await this.findById(id); + + if (item.status !== 'READY_FOR_LOADING') { + throw new BadRequestException(`Only READY_FOR_LOADING inventory can be loaded (current: ${item.status})`); + } + + await this.assertPaidBooking(this.dataSource.manager, item.bookingId); + await this.inventoryRepository.update(id, { status: 'LOADED' }); + return this.findById(id); + } + + async dispatch(id: string): Promise { + const item = await this.findById(id); + + if (item.status !== 'LOADED') { + throw new BadRequestException(`Only LOADED inventory can be dispatched (current: ${item.status})`); + } + + await this.inventoryRepository.update(id, { status: 'DISPATCHED' }); + return this.findById(id); + } + + async dashboardSummary(filter: FilterWarehouseInventoryDto): Promise { + const warehouses = await this.dataSource.getRepository(Warehouse).find({ + where: { + status: 'ACTIVE', + ...(filter.facilityId ? { stationId: filter.facilityId } : {}), + ...(filter.warehouseId ? { id: filter.warehouseId } : {}), + }, + }); + const inventory = await this.findAll(filter); + const today = new Date(); + + const byStatus = inventory.reduce>((acc, item) => { + acc[item.status] = (acc[item.status] ?? 0) + 1; + return acc; + }, {}); + + return { + totalWarehouses: warehouses.length, + totalInventory: inventory.length, + receivedToday: inventory.filter((item) => { + const arrivedAt = item.arrivedAt ?? item.createdAt; + return ( + arrivedAt.getFullYear() === today.getFullYear() && + arrivedAt.getMonth() === today.getMonth() && + arrivedAt.getDate() === today.getDate() + ); + }).length, + stored: byStatus.STORED ?? 0, + reserved: byStatus.RESERVED ?? 0, + readyForLoading: byStatus.READY_FOR_LOADING ?? 0, + loaded: byStatus.LOADED ?? 0, + dispatched: byStatus.DISPATCHED ?? 0, + }; + } + // ── Inquiry ──────────────────────────────────────────────────────────── async inquiry(filter: InquiryWarehouseInventoryDto): Promise { @@ -340,6 +450,45 @@ export class WarehouseInventoryService { } } + private async assertPaidBooking(manager: EntityManager, bookingId: string): Promise { + const rows = await manager.query( + `SELECT id FROM freight.bookings + WHERE id = $1 AND deleted_at IS NULL AND status = 'PAID' AND payment_status = 'PAID' + LIMIT 1`, + [bookingId], + ); + if (!rows || rows.length === 0) { + throw new BadRequestException('Only PAID bookings can reserve stored inventory.'); + } + } + + private async attachBookingSummaries(items: WarehouseInventory[]): Promise { + const bookingIds = Array.from(new Set(items.map((item) => item.bookingId).filter(Boolean))); + if (!bookingIds.length) return; + + const rows = await this.dataSource.manager.query( + `SELECT id, reference, status, payment_status + FROM freight.bookings + WHERE id = ANY($1::uuid[]) AND deleted_at IS NULL`, + [bookingIds], + ); + const byId = new Map( + rows.map((row: { id: string; reference: string; status: string; payment_status: string }) => [ + row.id, + { + id: row.id, + reference: row.reference, + status: row.status, + paymentStatus: row.payment_status, + }, + ]), + ); + + for (const item of items) { + Object.assign(item, { booking: byId.get(item.bookingId) ?? null }); + } + } + private assertCapacity( label: string, node: { capacityWeight?: number | null; capacityContainers?: number | null; currentWeight: number; currentContainers: number }, diff --git a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts index e5ad8d371..9c3985a4e 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.module.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { Yard } from '../rule-engine/entities/yard.entity'; import { WarehouseInventory } from './entities/warehouse-inventory.entity'; import { WarehouseYard } from './entities/warehouse-yard.entity'; import { WarehouseZone } from './entities/warehouse-zone.entity'; @@ -19,7 +20,7 @@ import { WarehousesRepository } from './warehouses.repository'; import { WarehousesService } from './warehouses.service'; @Module({ - imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory])], + imports: [TypeOrmModule.forFeature([Warehouse, WarehouseYard, WarehouseZone, WarehouseInventory, Yard])], controllers: [ WarehousesController, WarehouseYardsController, 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 a77a02678..5a5e59947 100644 --- a/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts +++ b/apps/edr-freight-api/src/modules/warehouses/warehouses.service.ts @@ -29,13 +29,14 @@ export class WarehousesService { return this.warehousesRepository.findAll({ where: whereClauses, + relations: { facility: true }, order: { code: 'ASC' }, }); } async findById(id: string): Promise { const warehouse = await this.warehousesRepository.findById(id, { - relations: { yards: { zones: true } }, + relations: { facility: true, yards: { zones: true } }, }); if (!warehouse) { diff --git a/apps/edr-freight-web/backoffice/src/App.tsx b/apps/edr-freight-web/backoffice/src/App.tsx index ddc85f10b..3ae51b0e5 100644 --- a/apps/edr-freight-web/backoffice/src/App.tsx +++ b/apps/edr-freight-web/backoffice/src/App.tsx @@ -42,8 +42,9 @@ import TrainSchedulingGlobalRulesPage from "./pages/trainScheduling/TrainSchedul import FleetResourcePage from "./pages/fleet/FleetResourcePage"; import { getCategorySidebarChildren } from "./pages/ruleEngine/config/resources"; import TrainDetailPage from "./pages/trains/TrainDetailPage"; -import RoutesPage from "./pages/fleet/RoutesPage"; -import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; +import RoutesPage from "./pages/fleet/RoutesPage"; +import WarehouseDashboardPage from "./pages/warehouses/WarehouseDashboardPage"; +import WarehouseListPage from "./pages/warehouses/WarehouseListPage"; import WarehouseDetailPage from "./pages/warehouses/WarehouseDetailPage"; import WarehouseInventoryPage from "./pages/warehouses/WarehouseInventoryPage"; import InventoryInquiryPage from "./pages/warehouses/InventoryInquiryPage"; @@ -122,13 +123,18 @@ const buildSidebarSections = (demoItems: SidebarItem[]): SidebarSection[] => [ ], }, { - title: "Warehouse Management", - items: [ - { - label: "Warehouses", - href: "/dashboard/warehouses", - icon: , - }, + title: "Warehouse Management", + items: [ + { + label: "Warehouse dashboard", + href: "/dashboard/warehouses", + icon: , + }, + { + label: "Warehouses", + href: "/dashboard/warehouses/list", + icon: , + }, { label: "Inventory", href: "/dashboard/warehouse-inventory", @@ -296,8 +302,10 @@ const App = () => { } /> } /> - } /> - } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts index a498580a7..1cef9e83f 100644 --- a/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts +++ b/apps/edr-freight-web/backoffice/src/components/layout/route-meta.ts @@ -64,6 +64,34 @@ const ROUTE_META: Array<{ prefix: string; meta: PageMeta }> = [ subtitle: "Manage route definitions built from freight yards", }, }, + { + prefix: "/dashboard/warehouses/list", + meta: { + title: "Warehouses", + subtitle: "Manage warehouses, yards, and zones", + }, + }, + { + prefix: "/dashboard/warehouse-inventory", + meta: { + title: "Warehouse inventory", + subtitle: "Track received items through inspection and loading", + }, + }, + { + prefix: "/dashboard/inventory-inquiry", + meta: { + title: "Inventory inquiry", + subtitle: "Locate cargo, containers, and goods inside the warehouse network", + }, + }, + { + prefix: "/dashboard/warehouses", + meta: { + title: "Warehouse dashboard", + subtitle: "Live overview of warehouse capacity and inventory lifecycle", + }, + }, ...getFleetRouteMeta(), { prefix: "/dashboard/trains/", diff --git a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx index 44bcb7c45..31d46e534 100644 --- a/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx +++ b/apps/edr-freight-web/backoffice/src/components/warehouses/CreateWarehouseModal.tsx @@ -10,7 +10,7 @@ import { } from '@mantine/core'; import { useToast } from '@/hooks/use-toast'; -import { useCreateWarehouse, useUpdateWarehouse } from '@/hooks/useWarehouses'; +import { useCreateWarehouse, useUpdateWarehouse, useWarehouseFacilities } from '@/hooks/useWarehouses'; import type { SaveWarehousePayload, Warehouse, WarehouseType } from '@/types/warehouse'; import { extractErrorMessage, statusOptions, warehouseTypeOptions } from './options'; @@ -24,6 +24,7 @@ interface FormState { name: string; code: string; type: WarehouseType; + stationId: string; locationName: string; capacityWeight: number | ''; capacityContainers: number | ''; @@ -34,6 +35,7 @@ const emptyForm = (): FormState => ({ name: '', code: '', type: 'OPEN_WAREHOUSE', + stationId: '', locationName: '', capacityWeight: '', capacityContainers: '', @@ -45,8 +47,14 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh const { toast } = useToast(); const createMutation = useCreateWarehouse(); const updateMutation = useUpdateWarehouse(); + const facilitiesQuery = useWarehouseFacilities(); const [form, setForm] = useState(emptyForm()); + const facilityOptions = (facilitiesQuery.data ?? []).map((facility) => ({ + value: facility.id, + label: `${facility.label ?? facility.name ?? facility.code} (${facility.code})`, + })); + useEffect(() => { if (opened) { setForm( @@ -55,6 +63,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh name: warehouse.name, code: warehouse.code, type: warehouse.type, + stationId: warehouse.stationId ?? '', locationName: warehouse.locationName ?? '', capacityWeight: warehouse.capacityWeight ?? '', capacityContainers: warehouse.capacityContainers ?? '', @@ -77,6 +86,7 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh name: form.name.trim(), code: form.code.trim(), type: form.type, + stationId: form.stationId || undefined, locationName: form.locationName.trim() || undefined, capacityWeight: form.capacityWeight === '' ? undefined : Number(form.capacityWeight), capacityContainers: form.capacityContainers === '' ? undefined : Number(form.capacityContainers), @@ -135,6 +145,16 @@ export function CreateWarehouseModal({ opened, onClose, warehouse }: CreateWareh )} + + setFilter((current) => ({ ...current, facilityId: value ?? undefined, warehouseId: undefined })) + } + w={260} + /> +