Warehouses confilct fix

This commit is contained in:
hagiye
2026-06-21 19:06:51 +03:00
parent a8cdbdf386
commit 79efa1ec60
7 changed files with 172 additions and 108 deletions

View File

@@ -60,7 +60,6 @@ import { RoutesModule } from './modules/routes/routes.module';
import { WarehousesModule } from './modules/warehouses/warehouses.module';
import { OverviewModule } from './modules/overview/overview.module';
import { FacilitiesModule } from './modules/facilities/facilities.module';
import { OverviewModule } from './modules/overview/overview.module';
import { VehiclesModule } from './modules/vehicles/vehicles.module';
import { DriversModule } from './modules/drivers/drivers.module';
@@ -122,8 +121,6 @@ import { DriversModule } from './modules/drivers/drivers.module';
WarehousesModule,
OverviewModule,
FacilitiesModule,
WarehousesModule,
OverviewModule,
VehiclesModule,
DriversModule,
],

View File

@@ -1,4 +1,4 @@
import { Module, forwardRef } from "@nestjs/common";
import { DynamicModule, Module, forwardRef } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { HttpModule } from "@nestjs/axios";
import { ConfigModule, ConfigService } from "@nestjs/config";
@@ -23,12 +23,10 @@ import { PaymentRefundEntity } from "./entities/payment-refund.entity";
const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
function rabbitMQImport(): DynamicModule[] {
if (!process.env.PAYMENT_RABBITMQ_URL) return [];
return [
RabbitMQModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService) => ({
@@ -49,6 +47,16 @@ const FREIGHT_QUEUE = PAYMENT_QUEUES[PaymentServiceEnum.FREIGHT];
connectionInitOptions: { wait: false },
}),
}),
];
}
@Module({
imports: [
HttpModule.register({ timeout: 10_000 }),
ConfigModule,
forwardRef(() => TrainSchedulingModule),
TypeOrmModule.forFeature([PaymentWebhookEventEntity, PaymentRefundEntity]),
...rabbitMQImport(),
],
providers: [
PaymentRepository,

View File

@@ -15,7 +15,6 @@ import { PaymentClientService } from "./payment-client.service";
import * as fs from "fs";
import * as path from "path";
import * as Handlebars from "handlebars";
import { ConfigService } from "@nestjs/config";
import { Booking } from "../bookings/entities/booking.entity";
import {

View File

@@ -1,4 +1,4 @@
import { Entity, Column, Index } from 'typeorm';
import { Entity, Column } from 'typeorm';
import { BaseEntity } from '@edr/api-common';
export enum VehicleType {

View File

@@ -28,10 +28,6 @@ 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

@@ -142,16 +142,4 @@ export class WarehouseInventoryController {
dispatch(@Param('id', ParseUUIDPipe) id: string, @Body('performedBy') performedBy?: string) {
return this.inventoryService.dispatch(id, performedBy);
}
@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

@@ -3,6 +3,8 @@ import { Between, DataSource, EntityManager, FindManyOptions, ILike, LessThanOrE
import { FilterWarehouseInventoryDto } from './dto/filter-inventory.dto';
import { InquiryWarehouseInventoryDto } from './dto/inquiry-inventory.dto';
import { LoadInventoryDto } from './dto/load-inventory.dto';
import { MoveInventoryDto } from './dto/move-inventory.dto';
import { ReceiveWarehouseInventoryDto } from './dto/receive-inventory.dto';
import { ReserveInventoryDto } from './dto/reserve-inventory.dto';
import { UnloadBookingDto } from './dto/unload-booking.dto';
@@ -46,6 +48,103 @@ export interface InventoryInquiryResult {
readyForLoadingAt: Date | null;
}
interface BookingSummaryRow {
id: string;
reference: string | null;
status: string | null;
customer: string | null;
}
interface ArrivalQueueRow {
bookingId: string;
bookingReference: string | null;
customer: string | null;
cargo: string | null;
container: string | null;
arrivalDate: Date | null;
bookingStatus: string | null;
inventoryId: string | null;
currentStatus: string | null;
inspectionStatus: string | null;
facility: string | null;
warehouse: string | null;
yard: string | null;
zone: string | null;
}
export interface ArrivalQueueItem {
bookingId: string;
bookingReference: string | null;
customer: string | null;
cargo: string | null;
container: string | null;
facility: string | null;
warehouse: string | null;
yard: string | null;
zone: string | null;
inventoryId: string | null;
currentStatus: string | null;
arrivalDate: Date | null;
inspectionStatus: string | null;
unloaded: boolean;
}
interface DefaultLocation {
warehouseId: string;
facilityId?: string | null;
yardId: string;
zoneId: string;
}
export interface AutoUnloadResult {
processedCount: number;
skippedCount: number;
failedCount: number;
results: Array<{
bookingId: string;
inventoryId?: string;
status: 'PROCESSED' | 'FAILED';
reason?: string;
}>;
}
export interface AutoLoadResult {
loadedCount: number;
skippedCount: number;
results: Array<{
inventoryId: string;
status: 'LOADED' | 'SKIPPED';
reason?: string;
}>;
}
interface WarehouseDashboardSummary {
totalWarehouses: number;
totalInventory: number;
receivedToday: number;
stored: number;
reserved: number;
readyForLoading: number;
loaded: number;
dispatched: number;
}
interface LocationRef {
warehouseId: string;
yardId: string;
zoneId: string;
}
interface LocationNode {
capacityWeight?: number | null;
capacityContainers?: number | null;
currentWeight: number;
maxWeight?: number | null;
maxVolume?: number | null;
currentVolume?: number | null;
currentContainers: number;
}
@Injectable()
export class WarehouseInventoryService {
constructor(
@@ -386,7 +485,7 @@ export class WarehouseInventoryService {
}),
);
await this.applyCapacityDelta(manager, dto.warehouseId, dto.yardId, dto.zoneId, weight, volume, containerCount, +1);
await this.applyCapacityDelta(manager, dto, weight, volume, containerCount);
await this.activityLog.record(
{
@@ -405,7 +504,7 @@ export class WarehouseInventoryService {
return this.findById(id);
}
async move(id: string, dto: MoveWarehouseInventoryDto): Promise<WarehouseInventory> {
async move(id: string, dto: MoveInventoryDto): Promise<WarehouseInventory> {
const movedId = await this.dataSource.transaction(async (manager) => {
const item = await manager.getRepository(WarehouseInventory).findOne({
where: { id },
@@ -428,12 +527,12 @@ export class WarehouseInventoryService {
const containerCount = item.containerId ? Math.round(Number(item.quantity) || 0) : 0;
if (item.warehouseId !== dto.warehouseId) {
this.assertCapacity('Warehouse', warehouse, weight, containerCount);
this.assertCapacity('Warehouse', warehouse, weight, Number(item.volume) || 0, containerCount);
}
if (item.yardId !== dto.yardId) {
this.assertCapacity('Yard', yard, weight, containerCount);
this.assertCapacity('Yard', yard, weight, Number(item.volume) || 0, containerCount);
}
this.assertCapacity('Zone', zone, weight, containerCount);
this.assertCapacity('Zone', zone, weight, Number(item.volume) || 0, containerCount);
await this.applyCapacityDelta(
manager,
@@ -443,10 +542,11 @@ export class WarehouseInventoryService {
zoneId: item.zoneId,
},
-weight,
-(Number(item.volume) || 0),
-containerCount,
);
await this.applyCapacityDelta(manager, dto, weight, containerCount);
await this.applyCapacityDelta(manager, dto, weight, Number(item.volume) || 0, containerCount);
item.warehouseId = dto.warehouseId;
item.yardId = dto.yardId;
@@ -597,34 +697,6 @@ 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);
}
// ── Loading records (Batch 3) ─────────────────────────────────────────────
async findLoadings(
@@ -663,44 +735,13 @@ export class WarehouseInventoryService {
});
}
async dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
const item = await this.findById(id);
if (item.status !== 'UNDER_INSPECTION') {
throw new BadRequestException(
`Only items in UNDER_INSPECTION can be marked READY_FOR_LOADING (current: ${item.status})`,
);
}
await this.inventoryRepository.update(id, {
status: 'READY_FOR_LOADING',
readyForLoadingAt: new Date(),
dispatch(id: string, performedBy?: string): Promise<WarehouseInventory> {
return this.transition(id, 'DISPATCHED', {
timestampField: 'dispatchedAt',
activityType: 'INVENTORY_DISPATCHED',
description: 'Inventory dispatched',
performedBy,
});
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> {
@@ -858,7 +899,7 @@ export class WarehouseInventoryService {
private async validateLocation(
manager: EntityManager,
dto: ReceiveWarehouseInventoryDto,
dto: LocationRef,
): Promise<{ warehouse: Warehouse; yard: WarehouseYard; zone: WarehouseZone }> {
const warehouse = await manager.getRepository(Warehouse).findOne({ where: { id: dto.warehouseId } });
if (!warehouse) throw new NotFoundException(`Warehouse ${dto.warehouseId} not found`);
@@ -879,6 +920,38 @@ export class WarehouseInventoryService {
}
}
private async getBookingStatus(bookingId: string): Promise<string | null> {
const [row]: Array<{ status: string | null }> = await this.dataSource.query(
'SELECT status FROM freight.bookings WHERE id = $1 AND deleted_at IS NULL LIMIT 1',
[bookingId],
);
return row?.status ?? null;
}
private async attachBookingSummaries(items: WarehouseInventory[]): Promise<void> {
const bookingIds = [...new Set(items.map((item) => item.bookingId).filter(Boolean))] as string[];
if (bookingIds.length === 0) return;
const rows: BookingSummaryRow[] = await this.dataSource.query(
`SELECT b.id, b.reference, b.status, company.name AS customer
FROM freight.bookings b
LEFT JOIN freight.companies company ON company.id = b.company_id
WHERE b.id = ANY($1) AND b.deleted_at IS NULL`,
[bookingIds],
);
const summaries = new Map(rows.map((row) => [row.id, row]));
items.forEach((item) => {
const summary = item.bookingId ? summaries.get(item.bookingId) : undefined;
if (!summary) return;
Object.assign(item, {
bookingReference: summary.reference,
bookingStatus: summary.status,
customerName: summary.customer,
});
});
}
private assertCapacity(
label: string,
node: LocationNode,
@@ -909,21 +982,24 @@ export class WarehouseInventoryService {
private async applyCapacityDelta(
manager: EntityManager,
dto: ReceiveWarehouseInventoryDto,
location: LocationRef,
weightAdd: number,
volumeAdd: number,
containerAdd: number,
): Promise<void> {
const apply = sign === 1 ? manager.increment.bind(manager) : manager.decrement.bind(manager);
const targets: Array<[typeof Warehouse | typeof WarehouseYard | typeof WarehouseZone, string]> = [
[Warehouse, warehouseId],
[WarehouseYard, yardId],
[WarehouseZone, zoneId],
[Warehouse, location.warehouseId],
[WarehouseYard, location.yardId],
[WarehouseZone, location.zoneId],
];
for (const [entity, id] of targets) {
if (weight) await apply(entity, { id }, 'currentWeight', weight);
if (volume) await apply(entity, { id }, 'currentVolume', volume);
if (containers) await apply(entity, { id }, 'currentContainers', containers);
if (weightAdd > 0) await manager.increment(entity, { id }, 'currentWeight', weightAdd);
if (weightAdd < 0) await manager.decrement(entity, { id }, 'currentWeight', Math.abs(weightAdd));
if (volumeAdd > 0) await manager.increment(entity, { id }, 'currentVolume', volumeAdd);
if (volumeAdd < 0) await manager.decrement(entity, { id }, 'currentVolume', Math.abs(volumeAdd));
if (containerAdd > 0) await manager.increment(entity, { id }, 'currentContainers', containerAdd);
if (containerAdd < 0) await manager.decrement(entity, { id }, 'currentContainers', Math.abs(containerAdd));
}
}
}