/** * Part Service * Mecánicas Diesel - ERP Suite * * Business logic for parts/inventory management. */ import { Repository, DataSource } from 'typeorm'; import { Part } from '../entities/part.entity'; // DTOs export interface CreatePartDto { sku: string; name: string; description?: string; categoryId?: string; preferredSupplierId?: string; brand?: string; manufacturer?: string; compatibleEngines?: string[]; unit?: string; cost?: number; price: number; minStock?: number; maxStock?: number; reorderPoint?: number; locationId?: string; barcode?: string; notes?: string; } export interface UpdatePartDto { name?: string; description?: string; categoryId?: string; preferredSupplierId?: string; brand?: string; manufacturer?: string; compatibleEngines?: string[]; unit?: string; cost?: number; price?: number; minStock?: number; maxStock?: number; reorderPoint?: number; locationId?: string; barcode?: string; isActive?: boolean; } export interface PartFilters { categoryId?: string; preferredSupplierId?: string; brand?: string; search?: string; lowStock?: boolean; isActive?: boolean; } export interface StockAdjustmentDto { quantity: number; reason: string; reference?: string; } export class PartService { private partRepository: Repository; constructor(dataSource: DataSource) { this.partRepository = dataSource.getRepository(Part); } /** * Create a new part */ async create(tenantId: string, dto: CreatePartDto): Promise { // Check SKU uniqueness const existing = await this.partRepository.findOne({ where: { tenantId, sku: dto.sku }, }); if (existing) { throw new Error(`Part with SKU ${dto.sku} already exists`); } const part = this.partRepository.create({ tenantId, sku: dto.sku, name: dto.name, description: dto.description, categoryId: dto.categoryId, preferredSupplierId: dto.preferredSupplierId, brand: dto.brand, manufacturer: dto.manufacturer, compatibleEngines: dto.compatibleEngines, unit: dto.unit || 'pza', cost: dto.cost, price: dto.price, minStock: dto.minStock || 0, maxStock: dto.maxStock, reorderPoint: dto.reorderPoint, locationId: dto.locationId, barcode: dto.barcode, currentStock: 0, reservedStock: 0, isActive: true, }); return this.partRepository.save(part); } /** * Find part by ID */ async findById(tenantId: string, id: string): Promise { return this.partRepository.findOne({ where: { id, tenantId }, }); } /** * Find part by SKU */ async findBySku(tenantId: string, sku: string): Promise { return this.partRepository.findOne({ where: { tenantId, sku }, }); } /** * Find part by barcode */ async findByBarcode(tenantId: string, barcode: string): Promise { return this.partRepository.findOne({ where: { tenantId, barcode }, }); } /** * List parts with filters */ async findAll( tenantId: string, filters: PartFilters = {}, pagination = { page: 1, limit: 20 } ) { const queryBuilder = this.partRepository.createQueryBuilder('part') .where('part.tenant_id = :tenantId', { tenantId }); if (filters.categoryId) { queryBuilder.andWhere('part.category_id = :categoryId', { categoryId: filters.categoryId }); } if (filters.preferredSupplierId) { queryBuilder.andWhere('part.preferred_supplier_id = :supplierId', { supplierId: filters.preferredSupplierId }); } if (filters.brand) { queryBuilder.andWhere('part.brand = :brand', { brand: filters.brand }); } if (filters.isActive !== undefined) { queryBuilder.andWhere('part.is_active = :isActive', { isActive: filters.isActive }); } if (filters.lowStock) { queryBuilder.andWhere('part.current_stock <= part.min_stock'); } if (filters.search) { queryBuilder.andWhere( '(part.sku ILIKE :search OR part.name ILIKE :search OR part.barcode ILIKE :search OR part.description ILIKE :search)', { search: `%${filters.search}%` } ); } const skip = (pagination.page - 1) * pagination.limit; const [data, total] = await queryBuilder .orderBy('part.name', 'ASC') .skip(skip) .take(pagination.limit) .getManyAndCount(); return { data, total, page: pagination.page, limit: pagination.limit, totalPages: Math.ceil(total / pagination.limit), }; } /** * Update part */ async update(tenantId: string, id: string, dto: UpdatePartDto): Promise { const part = await this.findById(tenantId, id); if (!part) return null; Object.assign(part, dto); return this.partRepository.save(part); } /** * Adjust stock (increase or decrease) */ async adjustStock( tenantId: string, id: string, dto: StockAdjustmentDto ): Promise { const part = await this.findById(tenantId, id); if (!part) return null; const newStock = part.currentStock + dto.quantity; if (newStock < 0) { throw new Error('Stock cannot be negative'); } part.currentStock = newStock; // TODO: Create stock movement record for audit trail return this.partRepository.save(part); } /** * Reserve stock for an order */ async reserveStock(tenantId: string, id: string, quantity: number): Promise { const part = await this.findById(tenantId, id); if (!part) return false; const availableStock = part.currentStock - part.reservedStock; if (quantity > availableStock) { throw new Error(`Insufficient stock. Available: ${availableStock}, Requested: ${quantity}`); } part.reservedStock += quantity; await this.partRepository.save(part); return true; } /** * Release reserved stock */ async releaseStock(tenantId: string, id: string, quantity: number): Promise { const part = await this.findById(tenantId, id); if (!part) return false; part.reservedStock = Math.max(0, part.reservedStock - quantity); await this.partRepository.save(part); return true; } /** * Consume reserved stock (when order is completed) */ async consumeStock(tenantId: string, id: string, quantity: number): Promise { const part = await this.findById(tenantId, id); if (!part) return false; part.reservedStock = Math.max(0, part.reservedStock - quantity); part.currentStock = Math.max(0, part.currentStock - quantity); await this.partRepository.save(part); return true; } /** * Get parts with low stock */ async getLowStockParts(tenantId: string): Promise { return this.partRepository .createQueryBuilder('part') .where('part.tenant_id = :tenantId', { tenantId }) .andWhere('part.is_active = true') .andWhere('part.current_stock <= part.min_stock') .orderBy('part.current_stock', 'ASC') .getMany(); } /** * Get inventory value */ async getInventoryValue(tenantId: string): Promise<{ totalCostValue: number; totalSaleValue: number; totalItems: number; lowStockCount: number; }> { const result = await this.partRepository .createQueryBuilder('part') .select('SUM(part.current_stock * COALESCE(part.cost, 0))', 'costValue') .addSelect('SUM(part.current_stock * part.price)', 'saleValue') .addSelect('SUM(part.current_stock)', 'totalItems') .where('part.tenant_id = :tenantId', { tenantId }) .andWhere('part.is_active = true') .getRawOne(); const lowStockCount = await this.partRepository .createQueryBuilder('part') .where('part.tenant_id = :tenantId', { tenantId }) .andWhere('part.is_active = true') .andWhere('part.current_stock <= part.min_stock') .getCount(); return { totalCostValue: parseFloat(result?.costValue) || 0, totalSaleValue: parseFloat(result?.saleValue) || 0, totalItems: parseInt(result?.totalItems, 10) || 0, lowStockCount, }; } /** * Search parts for autocomplete */ async search(tenantId: string, query: string, limit = 10): Promise { return this.partRepository .createQueryBuilder('part') .where('part.tenant_id = :tenantId', { tenantId }) .andWhere('part.is_active = true') .andWhere( '(part.sku ILIKE :query OR part.name ILIKE :query OR part.barcode ILIKE :query)', { query: `%${query}%` } ) .orderBy('part.name', 'ASC') .take(limit) .getMany(); } /** * Deactivate part */ async deactivate(tenantId: string, id: string): Promise { const part = await this.findById(tenantId, id); if (!part) return false; part.isActive = false; await this.partRepository.save(part); return true; } }