/** * Warehouse Entry Service * Mecánicas Diesel - ERP Suite * * Handles receiving parts into the warehouse/inventory. * Manages purchase entries, returns, transfers, and movement history. */ import { Repository, DataSource, Between, In, EntityManager } from 'typeorm'; import { Part } from '../entities/part.entity'; import { Supplier } from '../entities/supplier.entity'; import { InventoryMovement, MovementType, MovementReferenceType } from '../entities/inventory-movement.entity'; // ============================================================================ // DTOs // ============================================================================ export interface CreatePurchaseEntryDto { partId: string; quantity: number; unitCost: number; invoiceNumber?: string; notes?: string; } export interface BulkEntryDto { entries: CreatePurchaseEntryDto[]; } export interface BulkEntryResult { successful: Array<{ partId: string; movementId: string; quantity: number; }>; failed: Array<{ partId: string; error: string; }>; totalProcessed: number; totalSuccess: number; totalFailed: number; } export interface ReceiveFromSupplierItem { partId: string; quantity: number; unitCost: number; invoiceNumber?: string; } export interface ReceiveFromSupplierResult { supplierId: string; supplierName: string; totalItems: number; totalQuantity: number; totalValue: number; movements: InventoryMovement[]; receivedAt: Date; } export interface ReturnEntryDto { orderId: string; partId: string; quantity: number; reason: string; } export interface TransferDto { partId: string; fromLocationId: string; toLocationId: string; quantity: number; notes?: string; } export interface TransferResult { partId: string; fromLocationId: string; toLocationId: string; quantity: number; outMovementId: string; inMovementId: string; } export interface MovementHistoryFilters { startDate?: Date; endDate?: Date; movementTypes?: MovementType[]; page?: number; limit?: number; } export interface MovementHistoryResult { data: InventoryMovement[]; total: number; page: number; limit: number; totalPages: number; } export interface DailyMovementsSummary { date: Date; totalEntries: number; totalExits: number; netChange: number; byType: Record; totalValue: number; } // ============================================================================ // Service // ============================================================================ export class WarehouseEntryService { private partRepository: Repository; private supplierRepository: Repository; private movementRepository: Repository; private dataSource: DataSource; constructor(dataSource: DataSource) { this.dataSource = dataSource; this.partRepository = dataSource.getRepository(Part); this.supplierRepository = dataSource.getRepository(Supplier); this.movementRepository = dataSource.getRepository(InventoryMovement); } /** * Create a purchase entry - receive parts from purchase * Updates part stock and calculates weighted average cost */ async createPurchaseEntry( tenantId: string, dto: CreatePurchaseEntryDto, performedById: string ): Promise { return this.dataSource.transaction(async (manager) => { const partRepo = manager.getRepository(Part); const movementRepo = manager.getRepository(InventoryMovement); // Validate part exists const part = await partRepo.findOne({ where: { id: dto.partId, tenantId }, }); if (!part) { throw new Error(`Part with ID ${dto.partId} not found`); } if (dto.quantity <= 0) { throw new Error('Quantity must be greater than zero'); } if (dto.unitCost < 0) { throw new Error('Unit cost cannot be negative'); } const previousStock = part.currentStock; const newStock = previousStock + dto.quantity; const totalCost = dto.quantity * dto.unitCost; // Calculate weighted average cost const newAverageCost = this.calculateWeightedAverageCost( previousStock, part.cost || 0, dto.quantity, dto.unitCost ); // Create movement record const movement = movementRepo.create({ tenantId, partId: dto.partId, movementType: MovementType.PURCHASE, quantity: dto.quantity, unitCost: dto.unitCost, totalCost, previousStock, newStock, referenceType: MovementReferenceType.PURCHASE_ORDER, referenceId: undefined, notes: dto.invoiceNumber ? `Invoice: ${dto.invoiceNumber}${dto.notes ? ` - ${dto.notes}` : ''}` : dto.notes, performedById, performedAt: new Date(), }); await movementRepo.save(movement); // Update part stock and cost part.currentStock = newStock; part.cost = newAverageCost; await partRepo.save(part); return movement; }); } /** * Create bulk entries - receive multiple parts in a single transaction */ async createBulkEntry( tenantId: string, dto: BulkEntryDto, performedById: string ): Promise { const result: BulkEntryResult = { successful: [], failed: [], totalProcessed: dto.entries.length, totalSuccess: 0, totalFailed: 0, }; return this.dataSource.transaction(async (manager) => { for (const entry of dto.entries) { try { const movement = await this.createPurchaseEntryWithManager( manager, tenantId, entry, performedById ); result.successful.push({ partId: entry.partId, movementId: movement.id, quantity: entry.quantity, }); result.totalSuccess++; } catch (error) { const errorMessage = error instanceof Error ? error.message : 'Unknown error'; result.failed.push({ partId: entry.partId, error: errorMessage, }); result.totalFailed++; } } return result; }); } /** * Receive parts from a specific supplier * Full receiving workflow with supplier validation */ async receiveFromSupplier( tenantId: string, supplierId: string, items: ReceiveFromSupplierItem[], performedById: string ): Promise { return this.dataSource.transaction(async (manager) => { const supplierRepo = manager.getRepository(Supplier); // Validate supplier exists const supplier = await supplierRepo.findOne({ where: { id: supplierId, tenantId }, }); if (!supplier) { throw new Error(`Supplier with ID ${supplierId} not found`); } if (!supplier.isActive) { throw new Error(`Supplier ${supplier.name} is not active`); } const movements: InventoryMovement[] = []; let totalQuantity = 0; let totalValue = 0; // Process each item for (const item of items) { const movement = await this.createPurchaseEntryWithManager( manager, tenantId, { partId: item.partId, quantity: item.quantity, unitCost: item.unitCost, invoiceNumber: item.invoiceNumber, notes: `Received from supplier: ${supplier.name}`, }, performedById ); movements.push(movement); totalQuantity += item.quantity; totalValue += item.quantity * item.unitCost; } const receivedAt = new Date(); return { supplierId, supplierName: supplier.name, totalItems: items.length, totalQuantity, totalValue, movements, receivedAt, }; }); } /** * Create return entry - return part to stock from service order */ async createReturnEntry( tenantId: string, dto: ReturnEntryDto, performedById: string ): Promise { return this.dataSource.transaction(async (manager) => { const partRepo = manager.getRepository(Part); const movementRepo = manager.getRepository(InventoryMovement); // Validate part exists const part = await partRepo.findOne({ where: { id: dto.partId, tenantId }, }); if (!part) { throw new Error(`Part with ID ${dto.partId} not found`); } if (dto.quantity <= 0) { throw new Error('Return quantity must be greater than zero'); } const previousStock = part.currentStock; const newStock = previousStock + dto.quantity; // Create movement record const movement = movementRepo.create({ tenantId, partId: dto.partId, movementType: MovementType.RETURN, quantity: dto.quantity, unitCost: part.cost, totalCost: dto.quantity * (part.cost || 0), previousStock, newStock, referenceType: MovementReferenceType.RETURN, referenceId: dto.orderId, notes: `Return reason: ${dto.reason}`, performedById, performedAt: new Date(), }); await movementRepo.save(movement); // Update part stock (no cost recalculation for returns) part.currentStock = newStock; await partRepo.save(part); return movement; }); } /** * Create transfer between warehouse locations * Creates two movements: out from source, in to destination */ async createTransfer( tenantId: string, dto: TransferDto, performedById: string ): Promise { return this.dataSource.transaction(async (manager) => { const partRepo = manager.getRepository(Part); const movementRepo = manager.getRepository(InventoryMovement); // Validate part exists const part = await partRepo.findOne({ where: { id: dto.partId, tenantId }, }); if (!part) { throw new Error(`Part with ID ${dto.partId} not found`); } if (dto.quantity <= 0) { throw new Error('Transfer quantity must be greater than zero'); } if (dto.fromLocationId === dto.toLocationId) { throw new Error('Source and destination locations cannot be the same'); } // For transfers, stock remains the same (internal movement) // We create two movements for traceability const currentStock = part.currentStock; const performedAt = new Date(); // Create OUT movement const outMovement = movementRepo.create({ tenantId, partId: dto.partId, movementType: MovementType.TRANSFER, quantity: dto.quantity, unitCost: part.cost, totalCost: dto.quantity * (part.cost || 0), previousStock: currentStock, newStock: currentStock, // Stock doesn't change for internal transfers referenceType: MovementReferenceType.MANUAL, referenceId: dto.fromLocationId, notes: `Transfer OUT to location ${dto.toLocationId}${dto.notes ? ` - ${dto.notes}` : ''}`, performedById, performedAt, }); await movementRepo.save(outMovement); // Create IN movement const inMovement = movementRepo.create({ tenantId, partId: dto.partId, movementType: MovementType.TRANSFER, quantity: dto.quantity, unitCost: part.cost, totalCost: dto.quantity * (part.cost || 0), previousStock: currentStock, newStock: currentStock, referenceType: MovementReferenceType.MANUAL, referenceId: dto.toLocationId, notes: `Transfer IN from location ${dto.fromLocationId}${dto.notes ? ` - ${dto.notes}` : ''}`, performedById, performedAt, }); await movementRepo.save(inMovement); // Update part location if it has a location assigned if (part.locationId === dto.fromLocationId) { part.locationId = dto.toLocationId; await partRepo.save(part); } return { partId: dto.partId, fromLocationId: dto.fromLocationId, toLocationId: dto.toLocationId, quantity: dto.quantity, outMovementId: outMovement.id, inMovementId: inMovement.id, }; }); } /** * Get movement history for a specific part */ async getMovementHistory( tenantId: string, partId: string, filters: MovementHistoryFilters = {} ): Promise { const page = filters.page || 1; const limit = filters.limit || 20; const skip = (page - 1) * limit; const queryBuilder = this.movementRepository .createQueryBuilder('movement') .where('movement.tenant_id = :tenantId', { tenantId }) .andWhere('movement.part_id = :partId', { partId }); // Apply date range filter if (filters.startDate && filters.endDate) { queryBuilder.andWhere( 'movement.performed_at BETWEEN :startDate AND :endDate', { startDate: filters.startDate, endDate: filters.endDate } ); } else if (filters.startDate) { queryBuilder.andWhere('movement.performed_at >= :startDate', { startDate: filters.startDate, }); } else if (filters.endDate) { queryBuilder.andWhere('movement.performed_at <= :endDate', { endDate: filters.endDate, }); } // Apply movement type filter if (filters.movementTypes && filters.movementTypes.length > 0) { queryBuilder.andWhere('movement.movement_type IN (:...types)', { types: filters.movementTypes, }); } const [data, total] = await queryBuilder .orderBy('movement.performed_at', 'DESC') .skip(skip) .take(limit) .getManyAndCount(); return { data, total, page, limit, totalPages: Math.ceil(total / limit), }; } /** * Get all movements related to a specific service order */ async getMovementsByOrder( tenantId: string, orderId: string ): Promise { return this.movementRepository.find({ where: { tenantId, referenceId: orderId, referenceType: In([ MovementReferenceType.SERVICE_ORDER, MovementReferenceType.RETURN, ]), }, order: { performedAt: 'DESC' }, relations: ['part'], }); } /** * Recalculate weighted average cost for a part * Based on all purchase movements */ async calculateAverageCost(tenantId: string, partId: string): Promise { return this.dataSource.transaction(async (manager) => { const partRepo = manager.getRepository(Part); const movementRepo = manager.getRepository(InventoryMovement); const part = await partRepo.findOne({ where: { id: partId, tenantId }, }); if (!part) { throw new Error(`Part with ID ${partId} not found`); } // Get all purchase movements const purchaseMovements = await movementRepo.find({ where: { tenantId, partId, movementType: MovementType.PURCHASE, }, order: { performedAt: 'ASC' }, }); if (purchaseMovements.length === 0) { return part.cost || 0; } // Calculate weighted average using FIFO-like approach let totalQuantity = 0; let totalValue = 0; for (const movement of purchaseMovements) { totalQuantity += Number(movement.quantity); totalValue += Number(movement.totalCost || 0); } const averageCost = totalQuantity > 0 ? totalValue / totalQuantity : 0; // Update part cost part.cost = averageCost; await partRepo.save(part); return averageCost; }); } /** * Get daily movements summary for a specific date */ async getDailyMovementsSummary( tenantId: string, date: Date ): Promise { const startOfDay = new Date(date); startOfDay.setHours(0, 0, 0, 0); const endOfDay = new Date(date); endOfDay.setHours(23, 59, 59, 999); const movements = await this.movementRepository.find({ where: { tenantId, performedAt: Between(startOfDay, endOfDay), }, }); const inboundTypes = [ MovementType.PURCHASE, MovementType.ADJUSTMENT_IN, MovementType.RETURN, ]; const outboundTypes = [ MovementType.CONSUMPTION, MovementType.ADJUSTMENT_OUT, ]; let totalEntries = 0; let totalExits = 0; let totalValue = 0; const byType: Record = { [MovementType.PURCHASE]: { count: 0, quantity: 0, value: 0 }, [MovementType.CONSUMPTION]: { count: 0, quantity: 0, value: 0 }, [MovementType.ADJUSTMENT_IN]: { count: 0, quantity: 0, value: 0 }, [MovementType.ADJUSTMENT_OUT]: { count: 0, quantity: 0, value: 0 }, [MovementType.RETURN]: { count: 0, quantity: 0, value: 0 }, [MovementType.TRANSFER]: { count: 0, quantity: 0, value: 0 }, }; for (const movement of movements) { const quantity = Number(movement.quantity); const value = Number(movement.totalCost || 0); // Update type statistics byType[movement.movementType].count++; byType[movement.movementType].quantity += quantity; byType[movement.movementType].value += value; // Calculate totals if (inboundTypes.includes(movement.movementType)) { totalEntries += quantity; totalValue += value; } else if (outboundTypes.includes(movement.movementType)) { totalExits += quantity; totalValue -= value; } // Transfers don't affect totals } return { date: startOfDay, totalEntries, totalExits, netChange: totalEntries - totalExits, byType, totalValue, }; } /** * Create adjustment entry (manual stock correction) */ async createAdjustmentEntry( tenantId: string, partId: string, quantity: number, reason: string, performedById: string ): Promise { return this.dataSource.transaction(async (manager) => { const partRepo = manager.getRepository(Part); const movementRepo = manager.getRepository(InventoryMovement); const part = await partRepo.findOne({ where: { id: partId, tenantId }, }); if (!part) { throw new Error(`Part with ID ${partId} not found`); } const previousStock = part.currentStock; const newStock = previousStock + quantity; if (newStock < 0) { throw new Error('Adjustment would result in negative stock'); } const movementType = quantity >= 0 ? MovementType.ADJUSTMENT_IN : MovementType.ADJUSTMENT_OUT; const movement = movementRepo.create({ tenantId, partId, movementType, quantity: Math.abs(quantity), unitCost: part.cost, totalCost: Math.abs(quantity) * (part.cost || 0), previousStock, newStock, referenceType: MovementReferenceType.ADJUSTMENT, notes: reason, performedById, performedAt: new Date(), }); await movementRepo.save(movement); part.currentStock = newStock; await partRepo.save(part); return movement; }); } /** * Create consumption entry (part used in service order) */ async createConsumptionEntry( tenantId: string, partId: string, quantity: number, orderId: string, performedById: string, notes?: string ): Promise { return this.dataSource.transaction(async (manager) => { const partRepo = manager.getRepository(Part); const movementRepo = manager.getRepository(InventoryMovement); const part = await partRepo.findOne({ where: { id: partId, tenantId }, }); if (!part) { throw new Error(`Part with ID ${partId} not found`); } if (quantity <= 0) { throw new Error('Consumption quantity must be greater than zero'); } const previousStock = part.currentStock; const newStock = previousStock - quantity; if (newStock < 0) { throw new Error(`Insufficient stock. Available: ${previousStock}, Requested: ${quantity}`); } const movement = movementRepo.create({ tenantId, partId, movementType: MovementType.CONSUMPTION, quantity, unitCost: part.cost, totalCost: quantity * (part.cost || 0), previousStock, newStock, referenceType: MovementReferenceType.SERVICE_ORDER, referenceId: orderId, notes, performedById, performedAt: new Date(), }); await movementRepo.save(movement); part.currentStock = newStock; await partRepo.save(part); return movement; }); } /** * Get stock value report for all parts */ async getStockValueReport(tenantId: string): Promise<{ totalParts: number; totalQuantity: number; totalCostValue: number; totalSaleValue: number; averageMargin: number; }> { const result = await this.partRepository .createQueryBuilder('part') .select('COUNT(part.id)', 'totalParts') .addSelect('SUM(part.current_stock)', 'totalQuantity') .addSelect('SUM(part.current_stock * COALESCE(part.cost, 0))', 'totalCostValue') .addSelect('SUM(part.current_stock * part.price)', 'totalSaleValue') .where('part.tenant_id = :tenantId', { tenantId }) .andWhere('part.is_active = true') .getRawOne(); const totalCostValue = parseFloat(result?.totalCostValue) || 0; const totalSaleValue = parseFloat(result?.totalSaleValue) || 0; return { totalParts: parseInt(result?.totalParts, 10) || 0, totalQuantity: parseFloat(result?.totalQuantity) || 0, totalCostValue, totalSaleValue, averageMargin: totalCostValue > 0 ? ((totalSaleValue - totalCostValue) / totalCostValue) * 100 : 0, }; } // ============================================================================ // Private Helper Methods // ============================================================================ /** * Calculate weighted average cost when receiving new inventory */ private calculateWeightedAverageCost( existingQuantity: number, existingCost: number, newQuantity: number, newCost: number ): number { const totalQuantity = existingQuantity + newQuantity; if (totalQuantity === 0) { return 0; } const existingValue = existingQuantity * existingCost; const newValue = newQuantity * newCost; const totalValue = existingValue + newValue; return totalValue / totalQuantity; } /** * Internal method to create purchase entry with a provided EntityManager */ private async createPurchaseEntryWithManager( manager: EntityManager, tenantId: string, dto: CreatePurchaseEntryDto, performedById: string ): Promise { const partRepo = manager.getRepository(Part); const movementRepo = manager.getRepository(InventoryMovement); // Validate part exists const part = await partRepo.findOne({ where: { id: dto.partId, tenantId }, }); if (!part) { throw new Error(`Part with ID ${dto.partId} not found`); } if (dto.quantity <= 0) { throw new Error('Quantity must be greater than zero'); } if (dto.unitCost < 0) { throw new Error('Unit cost cannot be negative'); } const previousStock = part.currentStock; const newStock = previousStock + dto.quantity; const totalCost = dto.quantity * dto.unitCost; // Calculate weighted average cost const newAverageCost = this.calculateWeightedAverageCost( previousStock, part.cost || 0, dto.quantity, dto.unitCost ); // Create movement record const movement = movementRepo.create({ tenantId, partId: dto.partId, movementType: MovementType.PURCHASE, quantity: dto.quantity, unitCost: dto.unitCost, totalCost, previousStock, newStock, referenceType: MovementReferenceType.PURCHASE_ORDER, referenceId: undefined, notes: dto.invoiceNumber ? `Invoice: ${dto.invoiceNumber}${dto.notes ? ` - ${dto.notes}` : ''}` : dto.notes, performedById, performedAt: new Date(), }); await movementRepo.save(movement); // Update part stock and cost part.currentStock = newStock; part.cost = newAverageCost; await partRepo.save(part); return movement; } }