342 lines
8.8 KiB
TypeScript
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;
|
|
}
|
|
}
|