[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:
parent
71efafd139
commit
3a21a5a0fc
@ -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 });
|
||||
|
||||
@ -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<Part>;
|
||||
private movementRepository: Repository<InventoryMovement>;
|
||||
private supplierRepository: Repository<Supplier>;
|
||||
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<Part[]> {
|
||||
async getLowStockPartsSimple(tenantId: string): Promise<Part[]> {
|
||||
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<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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user