diff --git a/src/modules/inventory/services/almacen-proyecto.service.ts b/src/modules/inventory/services/almacen-proyecto.service.ts new file mode 100644 index 0000000..9c96b17 --- /dev/null +++ b/src/modules/inventory/services/almacen-proyecto.service.ts @@ -0,0 +1,650 @@ +/** + * 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); + }); + } +} diff --git a/src/modules/inventory/services/index.ts b/src/modules/inventory/services/index.ts index af14d2d..5ccc98c 100644 --- a/src/modules/inventory/services/index.ts +++ b/src/modules/inventory/services/index.ts @@ -3,5 +3,6 @@ * @module Inventory */ +export * from './almacen-proyecto.service'; export * from './requisicion.service'; export * from './consumo-obra.service';