From 3a21a5a0fcccfeee8b1206865d2d4161ff7620f5 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 02:46:00 -0600 Subject: [PATCH] [SPRINT-4] feat(parts-management): Implement complete PartsService business logic - Add checkStock() with available stock calculation considering reservations - Add reserveParts() with atomic transaction and pessimistic locking - Add updateInventory() supporting ENTRADA, SALIDA, AJUSTE, TRANSFERENCIA - Add calculateReorderPoint() with 90-day consumption analysis - Add getPartCompatibility() with alternate parts lookup - Add getLowStockParts() with criticality ranking (critical/warning/low) - Update controller to use enhanced low-stock endpoint - Export new DTOs: UpdateInventoryDto, ServiceContext, StockCheckResult, PartReservation, ReorderPointResult, PartCompatibilityResult, LowStockPart Co-Authored-By: Claude Opus 4.5 --- .../controllers/part.controller.ts | 5 +- .../parts-management/services/part.service.ts | 412 +++++++++++++++++- 2 files changed, 412 insertions(+), 5 deletions(-) diff --git a/src/modules/parts-management/controllers/part.controller.ts b/src/modules/parts-management/controllers/part.controller.ts index 471bdb2..f3c1fe4 100644 --- a/src/modules/parts-management/controllers/part.controller.ts +++ b/src/modules/parts-management/controllers/part.controller.ts @@ -84,12 +84,13 @@ export function createPartController(dataSource: DataSource): Router { }); /** - * Get parts with low stock + * Get parts with low stock (with criticality analysis) * GET /api/parts/low-stock */ router.get('/low-stock', async (req: TenantRequest, res: Response) => { try { - const parts = await service.getLowStockParts(req.tenantId!); + const ctx = { tenantId: req.tenantId!, userId: req.userId }; + const parts = await service.getLowStockParts(ctx); res.json(parts); } catch (error) { res.status(500).json({ error: (error as Error).message }); diff --git a/src/modules/parts-management/services/part.service.ts b/src/modules/parts-management/services/part.service.ts index 6e20204..317171f 100644 --- a/src/modules/parts-management/services/part.service.ts +++ b/src/modules/parts-management/services/part.service.ts @@ -5,8 +5,10 @@ * Business logic for parts/inventory management. */ -import { Repository, DataSource } from 'typeorm'; +import { Repository, DataSource, In, Between, MoreThanOrEqual } from 'typeorm'; import { Part } from '../entities/part.entity'; +import { InventoryMovement, MovementType, MovementReferenceType } from '../entities/inventory-movement.entity'; +import { Supplier } from '../entities/supplier.entity'; import { StockAlertService } from './stock-alert.service'; // DTOs @@ -64,14 +66,82 @@ export interface StockAdjustmentDto { reference?: string; } +export interface UpdateInventoryDto { + movementType: 'ENTRADA' | 'SALIDA' | 'AJUSTE' | 'TRANSFERENCIA'; + quantity: number; + reason: string; + referenceId?: string; + referenceType?: MovementReferenceType; + unitCost?: number; + performedById: string; +} + +export interface ServiceContext { + tenantId: string; + userId?: string; +} + +export interface StockCheckResult { + available: boolean; + currentStock: number; + reserved: number; + availableQty: number; +} + +export interface PartReservation { + id: string; + partId: string; + orderId: string; + quantity: number; + reservedAt: Date; +} + +export interface ReorderPointResult { + reorderPoint: number; + safetyStock: number; + suggestedOrderQty: number; + averageDailyConsumption: number; + leadTimeDays: number; +} + +export interface PartCompatibilityResult { + partId: string; + partSku: string; + partName: string; + compatibleEngines: string[]; + alternativeParts: Array<{ + id: string; + sku: string; + name: string; + alternateCode: string; + manufacturer?: string; + isPreferred: boolean; + }>; +} + +export interface LowStockPart { + partId: string; + sku: string; + name: string; + currentStock: number; + minStock: number; + reorderPoint: number | null; + daysOfStockRemaining: number; + criticality: 'critical' | 'warning' | 'low'; +} + export class PartService { private partRepository: Repository; + private movementRepository: Repository; + private supplierRepository: Repository; private dataSource: DataSource; private stockAlertService: StockAlertService | null = null; constructor(dataSource: DataSource) { this.dataSource = dataSource; this.partRepository = dataSource.getRepository(Part); + this.movementRepository = dataSource.getRepository(InventoryMovement); + this.supplierRepository = dataSource.getRepository(Supplier); } /** @@ -295,9 +365,10 @@ export class PartService { } /** - * Get parts with low stock + * Get parts with low stock (simple list) + * @deprecated Use getLowStockParts(ctx: ServiceContext) for enhanced criticality info */ - async getLowStockParts(tenantId: string): Promise { + async getLowStockPartsSimple(tenantId: string): Promise { return this.partRepository .createQueryBuilder('part') .where('part.tenant_id = :tenantId', { tenantId }) @@ -357,6 +428,341 @@ export class PartService { .getMany(); } + /** + * Check stock availability for a part + * Considers current stock minus reserved stock + */ + async checkStock(partId: string, quantity: number, ctx: ServiceContext): Promise { + const part = await this.partRepository.findOne({ + where: { id: partId, tenantId: ctx.tenantId }, + }); + + if (!part) { + throw new Error(`Part with ID ${partId} not found`); + } + + const currentStock = Number(part.currentStock); + const reserved = Number(part.reservedStock); + const availableQty = currentStock - reserved; + + return { + available: availableQty >= quantity, + currentStock, + reserved, + availableQty, + }; + } + + /** + * Reserve parts for an order + * Validates stock availability and creates reservations atomically + */ + async reserveParts( + orderId: string, + parts: Array<{ partId: string; quantity: number }>, + ctx: ServiceContext + ): Promise { + return this.dataSource.transaction(async (manager) => { + const partRepo = manager.getRepository(Part); + const reservations: PartReservation[] = []; + + for (const item of parts) { + const part = await partRepo.findOne({ + where: { id: item.partId, tenantId: ctx.tenantId }, + lock: { mode: 'pessimistic_write' }, + }); + + if (!part) { + throw new Error(`Part with ID ${item.partId} not found`); + } + + const availableStock = Number(part.currentStock) - Number(part.reservedStock); + + if (availableStock < item.quantity) { + throw new Error( + `Insufficient stock for part ${part.sku}. ` + + `Available: ${availableStock}, Requested: ${item.quantity}` + ); + } + + part.reservedStock = Number(part.reservedStock) + item.quantity; + await partRepo.save(part); + + reservations.push({ + id: `${orderId}-${item.partId}`, + partId: item.partId, + orderId, + quantity: item.quantity, + reservedAt: new Date(), + }); + } + + return reservations; + }); + } + + /** + * Update inventory with movement tracking + * Supports ENTRADA, SALIDA, AJUSTE, TRANSFERENCIA + */ + async updateInventory( + partId: string, + dto: UpdateInventoryDto, + ctx: ServiceContext + ): 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: ctx.tenantId }, + lock: { mode: 'pessimistic_write' }, + }); + + if (!part) { + throw new Error(`Part with ID ${partId} not found`); + } + + const previousStock = Number(part.currentStock); + let newStock: number; + let movementType: MovementType; + + switch (dto.movementType) { + case 'ENTRADA': + newStock = previousStock + dto.quantity; + movementType = MovementType.PURCHASE; + break; + case 'SALIDA': + newStock = previousStock - dto.quantity; + movementType = MovementType.CONSUMPTION; + break; + case 'AJUSTE': + newStock = previousStock + dto.quantity; + movementType = dto.quantity >= 0 ? MovementType.ADJUSTMENT_IN : MovementType.ADJUSTMENT_OUT; + break; + case 'TRANSFERENCIA': + newStock = previousStock; + movementType = MovementType.TRANSFER; + break; + default: + throw new Error(`Invalid movement type: ${dto.movementType}`); + } + + if (newStock < 0) { + throw new Error( + `Operation would result in negative stock. ` + + `Current: ${previousStock}, Change: ${dto.quantity}, Would be: ${newStock}` + ); + } + + const movement = movementRepo.create({ + tenantId: ctx.tenantId, + partId, + movementType, + quantity: Math.abs(dto.quantity), + unitCost: dto.unitCost ?? part.cost, + totalCost: Math.abs(dto.quantity) * (dto.unitCost ?? Number(part.cost) ?? 0), + previousStock, + newStock, + referenceType: dto.referenceType ?? MovementReferenceType.MANUAL, + referenceId: dto.referenceId, + notes: dto.reason, + performedById: dto.performedById, + performedAt: new Date(), + }); + + await movementRepo.save(movement); + + part.currentStock = newStock; + await partRepo.save(part); + + if (this.stockAlertService) { + await this.stockAlertService.checkPartStock(ctx.tenantId, partId); + } + + return movement; + }); + } + + /** + * Calculate reorder point based on consumption history + * Uses last 90 days of consumption data + */ + async calculateReorderPoint(partId: string, ctx: ServiceContext): Promise { + const part = await this.partRepository.findOne({ + where: { id: partId, tenantId: ctx.tenantId }, + relations: ['preferredSupplier'], + }); + + if (!part) { + throw new Error(`Part with ID ${partId} not found`); + } + + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + + const consumptionMovements = await this.movementRepository.find({ + where: { + tenantId: ctx.tenantId, + partId, + movementType: MovementType.CONSUMPTION, + performedAt: MoreThanOrEqual(ninetyDaysAgo), + }, + }); + + let totalConsumption = 0; + for (const movement of consumptionMovements) { + totalConsumption += Number(movement.quantity); + } + + const daysOfData = Math.min(90, Math.ceil( + (Date.now() - ninetyDaysAgo.getTime()) / (1000 * 60 * 60 * 24) + )); + + const averageDailyConsumption = daysOfData > 0 ? totalConsumption / daysOfData : 0; + + let leadTimeDays = 7; + if (part.preferredSupplierId) { + const supplier = await this.supplierRepository.findOne({ + where: { id: part.preferredSupplierId }, + }); + if (supplier) { + leadTimeDays = Math.max(supplier.creditDays, 7); + } + } + + const safetyStock = Math.ceil(averageDailyConsumption * Math.ceil(leadTimeDays * 0.5)); + const reorderPoint = Math.ceil(averageDailyConsumption * leadTimeDays) + safetyStock; + const suggestedOrderQty = Math.max( + Math.ceil(averageDailyConsumption * 30), + Number(part.minStock) * 2 + ); + + return { + reorderPoint, + safetyStock, + suggestedOrderQty, + averageDailyConsumption: Math.round(averageDailyConsumption * 100) / 100, + leadTimeDays, + }; + } + + /** + * Get part compatibility information + * Returns compatible engines and alternative parts + */ + async getPartCompatibility(partId: string, ctx: ServiceContext): Promise { + const part = await this.partRepository.findOne({ + where: { id: partId, tenantId: ctx.tenantId }, + }); + + if (!part) { + throw new Error(`Part with ID ${partId} not found`); + } + + const alternatesQuery = await this.dataSource + .createQueryBuilder() + .select([ + 'pa.id as id', + 'pa.alternate_code as "alternateCode"', + 'pa.manufacturer as manufacturer', + 'pa.is_preferred as "isPreferred"', + 'p.id as "partId"', + 'p.sku as sku', + 'p.name as name', + ]) + .from('parts_management.part_alternates', 'pa') + .leftJoin('parts_management.parts', 'p', 'p.sku = pa.alternate_code AND p.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .where('pa.part_id = :partId', { partId }) + .getRawMany(); + + const alternativeParts = alternatesQuery.map((row: any) => ({ + id: row.partId || row.id, + sku: row.sku || row.alternateCode, + name: row.name || `Alternate: ${row.alternateCode}`, + alternateCode: row.alternateCode, + manufacturer: row.manufacturer, + isPreferred: row.isPreferred, + })); + + return { + partId: part.id, + partSku: part.sku, + partName: part.name, + compatibleEngines: part.compatibleEngines || [], + alternativeParts, + }; + } + + /** + * Get parts with low stock ordered by criticality + * Criticality based on days of stock remaining + */ + async getLowStockParts(ctx: ServiceContext): Promise { + const lowStockParts = await this.partRepository + .createQueryBuilder('part') + .where('part.tenant_id = :tenantId', { tenantId: ctx.tenantId }) + .andWhere('part.is_active = true') + .andWhere('(part.current_stock <= part.min_stock OR part.current_stock <= part.reorder_point)') + .orderBy('part.current_stock', 'ASC') + .getMany(); + + const result: LowStockPart[] = []; + const ninetyDaysAgo = new Date(); + ninetyDaysAgo.setDate(ninetyDaysAgo.getDate() - 90); + + for (const part of lowStockParts) { + const consumptions = await this.movementRepository.find({ + where: { + tenantId: ctx.tenantId, + partId: part.id, + movementType: MovementType.CONSUMPTION, + performedAt: MoreThanOrEqual(ninetyDaysAgo), + }, + }); + + let totalConsumption = 0; + for (const m of consumptions) { + totalConsumption += Number(m.quantity); + } + + const avgDailyConsumption = totalConsumption / 90; + const currentStock = Number(part.currentStock); + const daysRemaining = avgDailyConsumption > 0 + ? Math.floor(currentStock / avgDailyConsumption) + : currentStock > 0 ? 999 : 0; + + let criticality: 'critical' | 'warning' | 'low'; + if (daysRemaining <= 0 || currentStock === 0) { + criticality = 'critical'; + } else if (daysRemaining <= 7) { + criticality = 'warning'; + } else { + criticality = 'low'; + } + + result.push({ + partId: part.id, + sku: part.sku, + name: part.name, + currentStock, + minStock: Number(part.minStock), + reorderPoint: part.reorderPoint !== undefined ? Number(part.reorderPoint) : null, + daysOfStockRemaining: daysRemaining, + criticality, + }); + } + + result.sort((a, b) => { + const criticalityOrder = { critical: 0, warning: 1, low: 2 }; + const diff = criticalityOrder[a.criticality] - criticalityOrder[b.criticality]; + if (diff !== 0) return diff; + return a.daysOfStockRemaining - b.daysOfStockRemaining; + }); + + return result; + } + /** * Deactivate part */