erp-mecanicas-diesel-backen.../src/modules/parts-management/services/part.service.ts
rckrdmrd 8ed7d24e96 Migración desde erp-mecanicas-diesel/backend - Estándar multi-repo v2
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-16 08:11:24 -06:00

342 lines
8.8 KiB
TypeScript

/**
* 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<Part>;
constructor(dataSource: DataSource) {
this.partRepository = dataSource.getRepository(Part);
}
/**
* Create a new part
*/
async create(tenantId: string, dto: CreatePartDto): Promise<Part> {
// 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<Part | null> {
return this.partRepository.findOne({
where: { id, tenantId },
});
}
/**
* Find part by SKU
*/
async findBySku(tenantId: string, sku: string): Promise<Part | null> {
return this.partRepository.findOne({
where: { tenantId, sku },
});
}
/**
* Find part by barcode
*/
async findByBarcode(tenantId: string, barcode: string): Promise<Part | null> {
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<Part | null> {
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<Part | null> {
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<boolean> {
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<boolean> {
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<boolean> {
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<Part[]> {
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<Part[]> {
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<boolean> {
const part = await this.findById(tenantId, id);
if (!part) return false;
part.isActive = false;
await this.partRepository.save(part);
return true;
}
}