[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
|
* 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 });
|
||||||
|
|||||||
@ -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
|
||||||
*/
|
*/
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user