/** * AlmacenProyectoService - Project Warehouse Management Service * * Manages project-specific warehouse assignments, stock queries by project, * transfers between project warehouses, and project inventory summaries. * * @module Inventory */ import { Repository, FindOptionsWhere, DataSource } from 'typeorm'; import { AlmacenProyecto, WarehouseTypeConstruction } from '../entities/almacen-proyecto.entity'; interface ServiceContext { tenantId: string; userId?: string; } interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export interface CreateAlmacenProyectoDto { warehouseId: string; fraccionamientoId: string; warehouseType?: WarehouseTypeConstruction; locationDescription?: string; responsibleId?: string; } export interface UpdateAlmacenProyectoDto { warehouseType?: WarehouseTypeConstruction; locationDescription?: string; responsibleId?: string; isActive?: boolean; } export interface AlmacenProyectoFilters { fraccionamientoId?: string; warehouseType?: WarehouseTypeConstruction; responsibleId?: string; isActive?: boolean; } export interface TransferRequest { sourceWarehouseId: string; destinationWarehouseId: string; productId: string; quantity: number; notes?: string; } export interface TransferResult { transferId: string; sourceWarehouseId: string; destinationWarehouseId: string; productId: string; quantity: number; transferredAt: Date; transferredById: string; } export interface StockByProject { productId: string; productName?: string; warehouseId: string; warehouseName?: string; quantity: number; reservedQuantity: number; availableQuantity: number; } export interface ProjectInventorySummary { fraccionamientoId: string; totalWarehouses: number; activeWarehouses: number; warehousesByType: { type: WarehouseTypeConstruction; count: number }[]; totalProducts: number; totalStockValue: number; } export class AlmacenProyectoService { constructor( private readonly repository: Repository, private readonly dataSource?: DataSource ) {} /** * Find project warehouses with filters and pagination */ async findWithFilters( ctx: ServiceContext, filters: AlmacenProyectoFilters = {}, page: number = 1, limit: number = 20 ): Promise> { const skip = (page - 1) * limit; const queryBuilder = this.repository .createQueryBuilder('ap') .leftJoinAndSelect('ap.fraccionamiento', 'fraccionamiento') .leftJoinAndSelect('ap.responsible', 'responsible') .where('ap.tenant_id = :tenantId', { tenantId: ctx.tenantId }) .andWhere('ap.deleted_at IS NULL'); if (filters.fraccionamientoId) { queryBuilder.andWhere('ap.fraccionamiento_id = :fraccionamientoId', { fraccionamientoId: filters.fraccionamientoId, }); } if (filters.warehouseType) { queryBuilder.andWhere('ap.warehouse_type = :warehouseType', { warehouseType: filters.warehouseType, }); } if (filters.responsibleId) { queryBuilder.andWhere('ap.responsible_id = :responsibleId', { responsibleId: filters.responsibleId, }); } if (filters.isActive !== undefined) { queryBuilder.andWhere('ap.is_active = :isActive', { isActive: filters.isActive, }); } queryBuilder .orderBy('ap.created_at', 'DESC') .skip(skip) .take(limit); const [data, total] = await queryBuilder.getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } /** * Find a project warehouse by ID */ async findById(ctx: ServiceContext, id: string): Promise { return this.repository.findOne({ where: { id, tenantId: ctx.tenantId, deletedAt: null, } as unknown as FindOptionsWhere, relations: ['fraccionamiento', 'responsible', 'createdBy'], }); } /** * Find a project warehouse by warehouse ID */ async findByWarehouseId(ctx: ServiceContext, warehouseId: string): Promise { return this.repository.findOne({ where: { warehouseId, tenantId: ctx.tenantId, deletedAt: null, } as unknown as FindOptionsWhere, relations: ['fraccionamiento', 'responsible'], }); } /** * Find all warehouses for a specific project (fraccionamiento) */ async findByProject(ctx: ServiceContext, fraccionamientoId: string): Promise { return this.repository.find({ where: { fraccionamientoId, tenantId: ctx.tenantId, deletedAt: null, } as unknown as FindOptionsWhere, relations: ['responsible'], order: { createdAt: 'ASC' }, }); } /** * Find active warehouses by type for a project */ async findByProjectAndType( ctx: ServiceContext, fraccionamientoId: string, warehouseType: WarehouseTypeConstruction ): Promise { return this.repository.find({ where: { fraccionamientoId, warehouseType, isActive: true, tenantId: ctx.tenantId, deletedAt: null, } as unknown as FindOptionsWhere, relations: ['responsible'], }); } /** * Create a new project warehouse assignment */ async create(ctx: ServiceContext, dto: CreateAlmacenProyectoDto): Promise { // Check if warehouse is already assigned to a project const existing = await this.findByWarehouseId(ctx, dto.warehouseId); if (existing) { throw new Error('Warehouse is already assigned to a project'); } const almacenProyecto = this.repository.create({ tenantId: ctx.tenantId, createdById: ctx.userId, warehouseId: dto.warehouseId, fraccionamientoId: dto.fraccionamientoId, warehouseType: dto.warehouseType || 'obra', locationDescription: dto.locationDescription, responsibleId: dto.responsibleId, isActive: true, }); return this.repository.save(almacenProyecto); } /** * Update a project warehouse */ async update( ctx: ServiceContext, id: string, dto: UpdateAlmacenProyectoDto ): Promise { const almacenProyecto = await this.findById(ctx, id); if (!almacenProyecto) { return null; } if (dto.warehouseType !== undefined) { almacenProyecto.warehouseType = dto.warehouseType; } if (dto.locationDescription !== undefined) { almacenProyecto.locationDescription = dto.locationDescription; } if (dto.responsibleId !== undefined) { almacenProyecto.responsibleId = dto.responsibleId; } if (dto.isActive !== undefined) { almacenProyecto.isActive = dto.isActive; } if (ctx.userId) { almacenProyecto.updatedById = ctx.userId; } return this.repository.save(almacenProyecto); } /** * Activate a project warehouse */ async activate(ctx: ServiceContext, id: string): Promise { return this.update(ctx, id, { isActive: true }); } /** * Deactivate a project warehouse */ async deactivate(ctx: ServiceContext, id: string): Promise { return this.update(ctx, id, { isActive: false }); } /** * Get stock by project (all warehouses) * Note: This queries against ERP Core inventory tables */ async getStockByProject( ctx: ServiceContext, fraccionamientoId: string ): Promise { if (!this.dataSource) { throw new Error('DataSource is required for stock queries'); } // Get all warehouse IDs for this project const projectWarehouses = await this.findByProject(ctx, fraccionamientoId); if (projectWarehouses.length === 0) { return []; } const warehouseIds = projectWarehouses.map((w) => w.warehouseId); // Query stock from ERP Core inventory.stock table const stockQuery = ` SELECT s.product_id as "productId", p.name as "productName", s.warehouse_id as "warehouseId", w.name as "warehouseName", COALESCE(s.quantity, 0) as quantity, COALESCE(s.reserved_quantity, 0) as "reservedQuantity", COALESCE(s.quantity, 0) - COALESCE(s.reserved_quantity, 0) as "availableQuantity" FROM inventory.stock s LEFT JOIN inventory.products p ON s.product_id = p.id LEFT JOIN inventory.warehouses w ON s.warehouse_id = w.id WHERE s.tenant_id = $1 AND s.warehouse_id = ANY($2::uuid[]) ORDER BY p.name, w.name `; try { const result = await this.dataSource.query(stockQuery, [ctx.tenantId, warehouseIds]); return result.map((row: Record) => ({ productId: row.productId as string, productName: row.productName as string | undefined, warehouseId: row.warehouseId as string, warehouseName: row.warehouseName as string | undefined, quantity: parseFloat(String(row.quantity) || '0'), reservedQuantity: parseFloat(String(row.reservedQuantity) || '0'), availableQuantity: parseFloat(String(row.availableQuantity) || '0'), })); } catch { // If inventory.stock table doesn't exist (ERP Core not fully set up), // return empty array return []; } } /** * Get stock for a specific product across all project warehouses */ async getProductStockByProject( ctx: ServiceContext, fraccionamientoId: string, productId: string ): Promise { const allStock = await this.getStockByProject(ctx, fraccionamientoId); return allStock.filter((s) => s.productId === productId); } /** * Transfer stock between project warehouses * Note: This creates movement records in ERP Core inventory */ async transferBetweenWarehouses( ctx: ServiceContext, request: TransferRequest ): Promise { if (!ctx.userId) { throw new Error('User ID is required for transfer operations'); } if (!this.dataSource) { throw new Error('DataSource is required for transfer operations'); } // Validate source warehouse belongs to a project const sourceWarehouse = await this.findByWarehouseId(ctx, request.sourceWarehouseId); if (!sourceWarehouse) { throw new Error('Source warehouse is not assigned to any project'); } // Validate destination warehouse belongs to a project const destWarehouse = await this.findByWarehouseId(ctx, request.destinationWarehouseId); if (!destWarehouse) { throw new Error('Destination warehouse is not assigned to any project'); } // Validate quantity if (request.quantity <= 0) { throw new Error('Transfer quantity must be positive'); } // Check available stock in source warehouse const sourceStock = await this.getWarehouseProductStock( ctx, request.sourceWarehouseId, request.productId ); if (sourceStock.availableQuantity < request.quantity) { throw new Error( `Insufficient stock. Available: ${sourceStock.availableQuantity}, Requested: ${request.quantity}` ); } // Create transfer movement in ERP Core const transferId = await this.createTransferMovement(ctx, request); return { transferId, sourceWarehouseId: request.sourceWarehouseId, destinationWarehouseId: request.destinationWarehouseId, productId: request.productId, quantity: request.quantity, transferredAt: new Date(), transferredById: ctx.userId, }; } /** * Get stock for a specific product in a warehouse */ private async getWarehouseProductStock( ctx: ServiceContext, warehouseId: string, productId: string ): Promise<{ quantity: number; reservedQuantity: number; availableQuantity: number }> { if (!this.dataSource) { return { quantity: 0, reservedQuantity: 0, availableQuantity: 0 }; } const query = ` SELECT COALESCE(quantity, 0) as quantity, COALESCE(reserved_quantity, 0) as "reservedQuantity" FROM inventory.stock WHERE tenant_id = $1 AND warehouse_id = $2 AND product_id = $3 `; try { const result = await this.dataSource.query(query, [ctx.tenantId, warehouseId, productId]); if (result.length === 0) { return { quantity: 0, reservedQuantity: 0, availableQuantity: 0 }; } const row = result[0]; const quantity = parseFloat(row.quantity || '0'); const reservedQuantity = parseFloat(row.reservedQuantity || '0'); return { quantity, reservedQuantity, availableQuantity: quantity - reservedQuantity, }; } catch { return { quantity: 0, reservedQuantity: 0, availableQuantity: 0 }; } } /** * Create a transfer movement between warehouses */ private async createTransferMovement( ctx: ServiceContext, request: TransferRequest ): Promise { if (!this.dataSource) { throw new Error('DataSource is required'); } const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { // Generate transfer ID const transferId = this.generateUUID(); const now = new Date(); // Create outbound movement from source await queryRunner.query( ` INSERT INTO inventory.stock_moves ( id, tenant_id, product_id, warehouse_id, quantity, movement_type, reference_type, reference_id, notes, created_at, created_by ) VALUES ($1, $2, $3, $4, $5, 'out', 'transfer', $6, $7, $8, $9) `, [ this.generateUUID(), ctx.tenantId, request.productId, request.sourceWarehouseId, -request.quantity, transferId, request.notes || 'Transfer between project warehouses', now, ctx.userId, ] ); // Create inbound movement to destination await queryRunner.query( ` INSERT INTO inventory.stock_moves ( id, tenant_id, product_id, warehouse_id, quantity, movement_type, reference_type, reference_id, notes, created_at, created_by ) VALUES ($1, $2, $3, $4, $5, 'in', 'transfer', $6, $7, $8, $9) `, [ this.generateUUID(), ctx.tenantId, request.productId, request.destinationWarehouseId, request.quantity, transferId, request.notes || 'Transfer between project warehouses', now, ctx.userId, ] ); // Update stock in source warehouse (decrease) await queryRunner.query( ` UPDATE inventory.stock SET quantity = quantity - $1, updated_at = $2 WHERE tenant_id = $3 AND warehouse_id = $4 AND product_id = $5 `, [request.quantity, now, ctx.tenantId, request.sourceWarehouseId, request.productId] ); // Update or insert stock in destination warehouse (increase) await queryRunner.query( ` INSERT INTO inventory.stock (id, tenant_id, warehouse_id, product_id, quantity, created_at) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (tenant_id, warehouse_id, product_id) DO UPDATE SET quantity = inventory.stock.quantity + $5, updated_at = $6 `, [ this.generateUUID(), ctx.tenantId, request.destinationWarehouseId, request.productId, request.quantity, now, ] ); await queryRunner.commitTransaction(); return transferId; } catch (error) { await queryRunner.rollbackTransaction(); throw error; } finally { await queryRunner.release(); } } /** * Get project inventory summary */ async getProjectInventorySummary( ctx: ServiceContext, fraccionamientoId: string ): Promise { // Get warehouses for the project const warehouses = await this.findByProject(ctx, fraccionamientoId); const activeWarehouses = warehouses.filter((w) => w.isActive); // Count by type const typeCount: Record = { central: 0, obra: 0, temporal: 0, transito: 0, }; for (const wh of activeWarehouses) { typeCount[wh.warehouseType]++; } const warehousesByType = Object.entries(typeCount) .filter(([, count]) => count > 0) .map(([type, count]) => ({ type: type as WarehouseTypeConstruction, count, })); // Get stock summary if dataSource available let totalProducts = 0; let totalStockValue = 0; if (this.dataSource && activeWarehouses.length > 0) { const warehouseIds = activeWarehouses.map((w) => w.warehouseId); try { const stockSummary = await this.dataSource.query( ` SELECT COUNT(DISTINCT s.product_id) as "totalProducts", COALESCE(SUM(s.quantity * COALESCE(p.cost, 0)), 0) as "totalValue" FROM inventory.stock s LEFT JOIN inventory.products p ON s.product_id = p.id WHERE s.tenant_id = $1 AND s.warehouse_id = ANY($2::uuid[]) AND s.quantity > 0 `, [ctx.tenantId, warehouseIds] ); if (stockSummary.length > 0) { totalProducts = parseInt(stockSummary[0].totalProducts || '0', 10); totalStockValue = parseFloat(stockSummary[0].totalValue || '0'); } } catch { // ERP Core tables may not exist } } return { fraccionamientoId, totalWarehouses: warehouses.length, activeWarehouses: activeWarehouses.length, warehousesByType, totalProducts, totalStockValue, }; } /** * Soft delete a project warehouse assignment */ async softDelete(ctx: ServiceContext, id: string): Promise { const almacenProyecto = await this.findById(ctx, id); if (!almacenProyecto) { return false; } await this.repository.update( { id, tenantId: ctx.tenantId } as unknown as FindOptionsWhere, { deletedAt: new Date(), deletedById: ctx.userId } ); return true; } /** * Generate a UUID v4 */ private generateUUID(): string { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0; const v = c === 'x' ? r : (r & 0x3) | 0x8; return v.toString(16); }); } }