[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 02:46:00 -06:00
parent 71efafd139
commit 3a21a5a0fc
2 changed files with 412 additions and 5 deletions

View File

@ -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 * GET /api/parts/low-stock
*/ */
router.get('/low-stock', async (req: TenantRequest, res: Response) => { router.get('/low-stock', async (req: TenantRequest, res: Response) => {
try { 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); res.json(parts);
} catch (error) { } catch (error) {
res.status(500).json({ error: (error as Error).message }); res.status(500).json({ error: (error as Error).message });

View File

@ -5,8 +5,10 @@
* Business logic for parts/inventory management. * 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 { 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'; import { StockAlertService } from './stock-alert.service';
// DTOs // DTOs
@ -64,14 +66,82 @@ export interface StockAdjustmentDto {
reference?: string; 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 { export class PartService {
private partRepository: Repository<Part>; private partRepository: Repository<Part>;
private movementRepository: Repository<InventoryMovement>;
private supplierRepository: Repository<Supplier>;
private dataSource: DataSource; private dataSource: DataSource;
private stockAlertService: StockAlertService | null = null; private stockAlertService: StockAlertService | null = null;
constructor(dataSource: DataSource) { constructor(dataSource: DataSource) {
this.dataSource = dataSource; this.dataSource = dataSource;
this.partRepository = dataSource.getRepository(Part); 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<Part[]> { async getLowStockPartsSimple(tenantId: string): Promise<Part[]> {
return this.partRepository return this.partRepository
.createQueryBuilder('part') .createQueryBuilder('part')
.where('part.tenant_id = :tenantId', { tenantId }) .where('part.tenant_id = :tenantId', { tenantId })
@ -357,6 +428,341 @@ export class PartService {
.getMany(); .getMany();
} }
/**
* Check stock availability for a part
* Considers current stock minus reserved stock
*/
async checkStock(partId: string, quantity: number, ctx: ServiceContext): Promise<StockCheckResult> {
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<PartReservation[]> {
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<InventoryMovement> {
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<ReorderPointResult> {
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<PartCompatibilityResult> {
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<LowStockPart[]> {
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 * Deactivate part
*/ */