warehouse

This commit is contained in:
hagiye
2026-06-19 17:13:17 +03:00
parent 63f177e6d0
commit 484ac187a9
22 changed files with 904 additions and 45 deletions

View File

@@ -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;
}

View File

@@ -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];

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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<WarehouseInventory[]> {
async findAll(filter: FilterWarehouseInventoryDto): Promise<WarehouseInventory[]> {
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<WarehouseInventory[]> {
@@ -69,7 +93,7 @@ export class WarehouseInventoryService {
async findById(id: string): Promise<WarehouseInventory> {
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<WarehouseInventory> {
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<WarehouseInventory> {
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<WarehouseInventory> {
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<WarehouseInventory> {
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<WarehouseInventory> {
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<WarehouseDashboardSummary> {
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<Record<string, number>>((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<InventoryInquiryResult[]> {
@@ -340,6 +450,45 @@ export class WarehouseInventoryService {
}
}
private async assertPaidBooking(manager: EntityManager, bookingId: string): Promise<void> {
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<void> {
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<string, { id: string; reference: string; status: string; paymentStatus: string }>(
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 },

View File

@@ -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,

View File

@@ -29,13 +29,14 @@ export class WarehousesService {
return this.warehousesRepository.findAll({
where: whereClauses,
relations: { facility: true },
order: { code: 'ASC' },
});
}
async findById(id: string): Promise<Warehouse> {
const warehouse = await this.warehousesRepository.findById(id, {
relations: { yards: { zones: true } },
relations: { facility: true, yards: { zones: true } },
});
if (!warehouse) {