From 74027be8043c56c47a9fe303a07eb93a7033ce09 Mon Sep 17 00:00:00 2001 From: Adrian Flores Cortes Date: Tue, 3 Feb 2026 01:15:27 -0600 Subject: [PATCH] [SPRINT-3] feat: Work execution and inventory management services - Add work-execution.service.ts (701 lines, 10+ methods) - Work lifecycle management (start, pause, complete) - Part consumption tracking and reservation - Time tracking with pause/resume - Quality control integration - Add warehouse-entry.service.ts for inventory entries - Add stock-alert.service.ts for minimum stock alerts - Add inventory-movement.entity.ts for kardex tracking - Add stock-alert.entity.ts for alert management - Enhance part.service.ts with new inventory methods Co-Authored-By: Claude Opus 4.5 --- .../parts-management/entities/index.ts | 2 + .../entities/inventory-movement.entity.ts | 129 ++ .../entities/stock-alert.entity.ts | 121 ++ src/modules/parts-management/index.ts | 26 + .../parts-management/services/part.service.ts | 32 +- .../services/stock-alert.service.ts | 829 +++++++++++++ .../services/warehouse-entry.service.ts | 900 ++++++++++++++ .../services/work-execution.service.ts | 1101 +++++++++++++++++ 8 files changed, 3139 insertions(+), 1 deletion(-) create mode 100644 src/modules/parts-management/entities/inventory-movement.entity.ts create mode 100644 src/modules/parts-management/entities/stock-alert.entity.ts create mode 100644 src/modules/parts-management/services/stock-alert.service.ts create mode 100644 src/modules/parts-management/services/warehouse-entry.service.ts create mode 100644 src/modules/service-management/services/work-execution.service.ts diff --git a/src/modules/parts-management/entities/index.ts b/src/modules/parts-management/entities/index.ts index 8caa5ba..b317b03 100644 --- a/src/modules/parts-management/entities/index.ts +++ b/src/modules/parts-management/entities/index.ts @@ -7,3 +7,5 @@ export * from './part.entity'; export * from './part-category.entity'; export * from './supplier.entity'; export * from './warehouse-location.entity'; +export * from './stock-alert.entity'; +export * from './inventory-movement.entity'; diff --git a/src/modules/parts-management/entities/inventory-movement.entity.ts b/src/modules/parts-management/entities/inventory-movement.entity.ts new file mode 100644 index 0000000..c7e8b48 --- /dev/null +++ b/src/modules/parts-management/entities/inventory-movement.entity.ts @@ -0,0 +1,129 @@ +/** + * Inventory Movement Entity + * Mecánicas Diesel - ERP Suite + * + * Tracks all inventory movements (purchases, consumptions, adjustments, returns, transfers). + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, + Check, +} from 'typeorm'; +import { Part } from './part.entity'; + +/** + * Type of inventory movement + */ +export enum MovementType { + PURCHASE = 'purchase', + CONSUMPTION = 'consumption', + ADJUSTMENT_IN = 'adjustment_in', + ADJUSTMENT_OUT = 'adjustment_out', + RETURN = 'return', + TRANSFER = 'transfer', +} + +/** + * Reference type for the movement source + */ +export enum MovementReferenceType { + SERVICE_ORDER = 'service_order', + PURCHASE_ORDER = 'purchase_order', + ADJUSTMENT = 'adjustment', + RETURN = 'return', + MANUAL = 'manual', +} + +@Entity({ name: 'inventory_movements', schema: 'parts_management' }) +@Index('idx_movements_tenant', ['tenantId']) +@Index('idx_movements_part', ['partId']) +@Index('idx_movements_type', ['tenantId', 'movementType']) +@Index('idx_movements_reference', ['referenceType', 'referenceId']) +@Index('idx_movements_performed_at', ['tenantId', 'performedAt']) +@Check('chk_quantity_positive', '"quantity" >= 0') +export class InventoryMovement { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'part_id', type: 'uuid' }) + partId: string; + + @Column({ + name: 'movement_type', + type: 'varchar', + length: 30, + }) + movementType: MovementType; + + @Column({ type: 'decimal', precision: 10, scale: 3 }) + quantity: number; + + @Column({ name: 'unit_cost', type: 'decimal', precision: 12, scale: 2, nullable: true }) + unitCost?: number; + + @Column({ name: 'total_cost', type: 'decimal', precision: 12, scale: 2, nullable: true }) + totalCost?: number; + + @Column({ name: 'previous_stock', type: 'decimal', precision: 10, scale: 3 }) + previousStock: number; + + @Column({ name: 'new_stock', type: 'decimal', precision: 10, scale: 3 }) + newStock: number; + + @Column({ + name: 'reference_type', + type: 'varchar', + length: 30, + }) + referenceType: MovementReferenceType; + + @Column({ name: 'reference_id', type: 'uuid', nullable: true }) + referenceId?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + @Column({ name: 'performed_by_id', type: 'uuid' }) + performedById: string; + + @Column({ name: 'performed_at', type: 'timestamptz' }) + performedAt: Date; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + /** + * Whether this movement increases stock + */ + get isInbound(): boolean { + return [ + MovementType.PURCHASE, + MovementType.ADJUSTMENT_IN, + MovementType.RETURN, + ].includes(this.movementType); + } + + /** + * Whether this movement decreases stock + */ + get isOutbound(): boolean { + return [ + MovementType.CONSUMPTION, + MovementType.ADJUSTMENT_OUT, + ].includes(this.movementType); + } + + // Relations + @ManyToOne(() => Part, { nullable: false }) + @JoinColumn({ name: 'part_id' }) + part: Part; +} diff --git a/src/modules/parts-management/entities/stock-alert.entity.ts b/src/modules/parts-management/entities/stock-alert.entity.ts new file mode 100644 index 0000000..d2fca6a --- /dev/null +++ b/src/modules/parts-management/entities/stock-alert.entity.ts @@ -0,0 +1,121 @@ +/** + * Stock Alert Entity + * Mecánicas Diesel - ERP Suite + * + * Represents inventory alerts for stock levels. + */ + +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + ManyToOne, + JoinColumn, + Index, +} from 'typeorm'; +import { Part } from './part.entity'; + +export enum StockAlertType { + LOW_STOCK = 'low_stock', + OUT_OF_STOCK = 'out_of_stock', + OVERSTOCK = 'overstock', +} + +export enum StockAlertStatus { + ACTIVE = 'active', + ACKNOWLEDGED = 'acknowledged', + RESOLVED = 'resolved', +} + +@Entity({ name: 'stock_alerts', schema: 'parts_management' }) +@Index('idx_stock_alerts_tenant', ['tenantId']) +@Index('idx_stock_alerts_part', ['partId']) +@Index('idx_stock_alerts_status', ['status']) +@Index('idx_stock_alerts_type', ['alertType']) +@Index('idx_stock_alerts_created', ['createdAt']) +@Index('idx_stock_alerts_active_part', ['partId', 'alertType', 'status']) +export class StockAlert { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'tenant_id', type: 'uuid' }) + tenantId: string; + + @Column({ name: 'part_id', type: 'uuid' }) + partId: string; + + @Column({ + name: 'alert_type', + type: 'varchar', + length: 20, + enum: StockAlertType, + }) + alertType: StockAlertType; + + @Column({ + type: 'varchar', + length: 20, + enum: StockAlertStatus, + default: StockAlertStatus.ACTIVE, + }) + status: StockAlertStatus; + + @Column({ + name: 'current_stock', + type: 'decimal', + precision: 10, + scale: 3, + comment: 'Stock level at time of alert creation', + }) + currentStock: number; + + @Column({ + type: 'decimal', + precision: 10, + scale: 3, + comment: 'minStock or maxStock threshold that triggered the alert', + }) + threshold: number; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @Column({ name: 'acknowledged_at', type: 'timestamptz', nullable: true }) + acknowledgedAt?: Date; + + @Column({ name: 'acknowledged_by_id', type: 'uuid', nullable: true }) + acknowledgedById?: string; + + @Column({ name: 'resolved_at', type: 'timestamptz', nullable: true }) + resolvedAt?: Date; + + @Column({ name: 'resolved_by_id', type: 'uuid', nullable: true }) + resolvedById?: string; + + @Column({ type: 'text', nullable: true }) + notes?: string; + + // Computed properties + get severityLevel(): number { + switch (this.alertType) { + case StockAlertType.OUT_OF_STOCK: + return 3; // Critical + case StockAlertType.LOW_STOCK: + return 2; // Warning + case StockAlertType.OVERSTOCK: + return 1; // Info + default: + return 0; + } + } + + get isUrgent(): boolean { + return this.alertType === StockAlertType.OUT_OF_STOCK; + } + + // Relations + @ManyToOne(() => Part, { nullable: false }) + @JoinColumn({ name: 'part_id' }) + part: Part; +} diff --git a/src/modules/parts-management/index.ts b/src/modules/parts-management/index.ts index 759f6f0..9b55015 100644 --- a/src/modules/parts-management/index.ts +++ b/src/modules/parts-management/index.ts @@ -8,10 +8,36 @@ export { Part } from './entities/part.entity'; export { PartCategory } from './entities/part-category.entity'; export { Supplier } from './entities/supplier.entity'; export { WarehouseLocation } from './entities/warehouse-location.entity'; +export { StockAlert, StockAlertType, StockAlertStatus } from './entities/stock-alert.entity'; +export { InventoryMovement, MovementType, MovementReferenceType } from './entities/inventory-movement.entity'; // Services export { PartService, CreatePartDto, UpdatePartDto, PartFilters, StockAdjustmentDto } from './services/part.service'; export { SupplierService, CreateSupplierDto, UpdateSupplierDto } from './services/supplier.service'; +export { + StockAlertService, + AlertFilters, + AcknowledgeAlertDto, + ResolveAlertDto, + ReorderSuggestion, + AlertStats, + NotificationData, + AlertNotificationBatch, +} from './services/stock-alert.service'; +export { + WarehouseEntryService, + CreatePurchaseEntryDto, + BulkEntryDto, + BulkEntryResult, + ReceiveFromSupplierItem, + ReceiveFromSupplierResult, + ReturnEntryDto, + TransferDto, + TransferResult, + MovementHistoryFilters, + MovementHistoryResult, + DailyMovementsSummary, +} from './services/warehouse-entry.service'; // Controllers export { createPartController } from './controllers/part.controller'; diff --git a/src/modules/parts-management/services/part.service.ts b/src/modules/parts-management/services/part.service.ts index 4f92dac..6e20204 100644 --- a/src/modules/parts-management/services/part.service.ts +++ b/src/modules/parts-management/services/part.service.ts @@ -7,6 +7,7 @@ import { Repository, DataSource } from 'typeorm'; import { Part } from '../entities/part.entity'; +import { StockAlertService } from './stock-alert.service'; // DTOs export interface CreatePartDto { @@ -65,11 +66,31 @@ export interface StockAdjustmentDto { export class PartService { private partRepository: Repository; + private dataSource: DataSource; + private stockAlertService: StockAlertService | null = null; constructor(dataSource: DataSource) { + this.dataSource = dataSource; this.partRepository = dataSource.getRepository(Part); } + /** + * Set the stock alert service for integration + * This allows checking stock alerts after stock changes + */ + setStockAlertService(stockAlertService: StockAlertService): void { + this.stockAlertService = stockAlertService; + } + + /** + * Helper to check stock alerts after a stock change + */ + private async checkStockAlerts(tenantId: string, partId: string): Promise { + if (this.stockAlertService) { + await this.stockAlertService.checkPartStock(tenantId, partId); + } + } + /** * Create a new part */ @@ -218,7 +239,12 @@ export class PartService { // TODO: Create stock movement record for audit trail - return this.partRepository.save(part); + const savedPart = await this.partRepository.save(part); + + // Check and update stock alerts after adjustment + await this.checkStockAlerts(tenantId, id); + + return savedPart; } /** @@ -261,6 +287,10 @@ export class PartService { part.reservedStock = Math.max(0, part.reservedStock - quantity); part.currentStock = Math.max(0, part.currentStock - quantity); await this.partRepository.save(part); + + // Check and update stock alerts after consuming stock + await this.checkStockAlerts(tenantId, id); + return true; } diff --git a/src/modules/parts-management/services/stock-alert.service.ts b/src/modules/parts-management/services/stock-alert.service.ts new file mode 100644 index 0000000..b4ea135 --- /dev/null +++ b/src/modules/parts-management/services/stock-alert.service.ts @@ -0,0 +1,829 @@ +/** + * Stock Alert Service + * Mecánicas Diesel - ERP Suite + * + * Business logic for stock level alerts and reorder management. + */ + +import { Repository, DataSource, In, IsNull, Not, LessThanOrEqual, MoreThan } from 'typeorm'; +import { StockAlert, StockAlertType, StockAlertStatus } from '../entities/stock-alert.entity'; +import { Part } from '../entities/part.entity'; + +// DTOs +export interface AlertFilters { + alertType?: StockAlertType; + status?: StockAlertStatus; + partId?: string; + supplierId?: string; + dateFrom?: Date; + dateTo?: Date; +} + +export interface AcknowledgeAlertDto { + notes?: string; +} + +export interface ResolveAlertDto { + notes?: string; +} + +export interface ReorderSuggestion { + partId: string; + partSku: string; + partName: string; + supplierId: string | null; + supplierName: string | null; + currentStock: number; + minStock: number; + reorderPoint: number | null; + suggestedQuantity: number; + estimatedCost: number | null; + alertId: string; + alertType: StockAlertType; +} + +export interface AlertStats { + byType: { + lowStock: number; + outOfStock: number; + overstock: number; + }; + byStatus: { + active: number; + acknowledged: number; + resolved: number; + }; + topRecurringParts: Array<{ + partId: string; + partSku: string; + partName: string; + alertCount: number; + }>; +} + +export interface NotificationData { + alertId: string; + alertType: StockAlertType; + severity: 'critical' | 'warning' | 'info'; + partSku: string; + partName: string; + currentStock: number; + threshold: number; + message: string; + createdAt: Date; +} + +export interface AlertNotificationBatch { + critical: NotificationData[]; + warning: NotificationData[]; + info: NotificationData[]; + summary: { + totalAlerts: number; + criticalCount: number; + warningCount: number; + infoCount: number; + }; +} + +export class StockAlertService { + private alertRepository: Repository; + private partRepository: Repository; + private dataSource: DataSource; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + this.alertRepository = dataSource.getRepository(StockAlert); + this.partRepository = dataSource.getRepository(Part); + } + + /** + * Check all parts and generate alerts for stock issues + * Intended to be run as a scheduled job + */ + async checkAndGenerateAlerts(tenantId: string): Promise<{ + newAlerts: number; + lowStock: number; + outOfStock: number; + overstock: number; + }> { + let lowStockCount = 0; + let outOfStockCount = 0; + let overstockCount = 0; + + // Get all active parts for the tenant + const parts = await this.partRepository.find({ + where: { tenantId, isActive: true }, + }); + + for (const part of parts) { + // Check for out of stock (highest priority) + if (part.currentStock === 0) { + const created = await this.createAlertIfNotExists( + tenantId, + part.id, + StockAlertType.OUT_OF_STOCK, + part.currentStock, + part.minStock + ); + if (created) outOfStockCount++; + } + // Check for low stock (only if not out of stock) + else if (part.currentStock <= part.minStock && part.currentStock > 0) { + const created = await this.createAlertIfNotExists( + tenantId, + part.id, + StockAlertType.LOW_STOCK, + part.currentStock, + part.minStock + ); + if (created) lowStockCount++; + } + + // Check for overstock + if (part.maxStock !== null && part.maxStock !== undefined && part.currentStock > part.maxStock) { + const created = await this.createAlertIfNotExists( + tenantId, + part.id, + StockAlertType.OVERSTOCK, + part.currentStock, + part.maxStock + ); + if (created) overstockCount++; + } + } + + const newAlerts = lowStockCount + outOfStockCount + overstockCount; + + return { + newAlerts, + lowStock: lowStockCount, + outOfStock: outOfStockCount, + overstock: overstockCount, + }; + } + + /** + * Check a single part's stock and generate/resolve alerts as needed + */ + async checkPartStock(tenantId: string, partId: string): Promise<{ + alertsCreated: string[]; + alertsResolved: string[]; + }> { + const alertsCreated: string[] = []; + const alertsResolved: string[] = []; + + const part = await this.partRepository.findOne({ + where: { id: partId, tenantId }, + }); + + if (!part || !part.isActive) { + return { alertsCreated, alertsResolved }; + } + + // Determine current stock conditions + const isOutOfStock = part.currentStock === 0; + const isLowStock = part.currentStock > 0 && part.currentStock <= part.minStock; + const isOverstock = part.maxStock !== null && part.maxStock !== undefined && part.currentStock > part.maxStock; + + // Get existing active alerts for this part + const existingAlerts = await this.alertRepository.find({ + where: { + tenantId, + partId, + status: In([StockAlertStatus.ACTIVE, StockAlertStatus.ACKNOWLEDGED]), + }, + }); + + const existingAlertTypes = new Set(existingAlerts.map(a => a.alertType)); + + // Create new alerts if conditions are met and no active alert exists + if (isOutOfStock && !existingAlertTypes.has(StockAlertType.OUT_OF_STOCK)) { + const alert = await this.createAlert( + tenantId, + partId, + StockAlertType.OUT_OF_STOCK, + part.currentStock, + part.minStock + ); + alertsCreated.push(alert.id); + } + + if (isLowStock && !existingAlertTypes.has(StockAlertType.LOW_STOCK)) { + const alert = await this.createAlert( + tenantId, + partId, + StockAlertType.LOW_STOCK, + part.currentStock, + part.minStock + ); + alertsCreated.push(alert.id); + } + + if (isOverstock && !existingAlertTypes.has(StockAlertType.OVERSTOCK)) { + const alert = await this.createAlert( + tenantId, + partId, + StockAlertType.OVERSTOCK, + part.currentStock, + part.maxStock! + ); + alertsCreated.push(alert.id); + } + + // Auto-resolve alerts where conditions no longer apply + for (const alert of existingAlerts) { + let shouldResolve = false; + + switch (alert.alertType) { + case StockAlertType.OUT_OF_STOCK: + shouldResolve = part.currentStock > 0; + break; + case StockAlertType.LOW_STOCK: + shouldResolve = part.currentStock > part.minStock || part.currentStock === 0; + break; + case StockAlertType.OVERSTOCK: + shouldResolve = part.maxStock === null || part.maxStock === undefined || part.currentStock <= part.maxStock; + break; + } + + if (shouldResolve) { + alert.status = StockAlertStatus.RESOLVED; + alert.resolvedAt = new Date(); + alert.notes = alert.notes + ? `${alert.notes}\nAuto-resolved: stock normalized to ${part.currentStock}` + : `Auto-resolved: stock normalized to ${part.currentStock}`; + await this.alertRepository.save(alert); + alertsResolved.push(alert.id); + } + } + + return { alertsCreated, alertsResolved }; + } + + /** + * Get active alerts with filtering and sorting by severity + */ + async getActiveAlerts( + tenantId: string, + filters: AlertFilters = {}, + pagination = { page: 1, limit: 20 } + ): Promise<{ + data: StockAlert[]; + total: number; + page: number; + limit: number; + totalPages: number; + }> { + const queryBuilder = this.alertRepository + .createQueryBuilder('alert') + .leftJoinAndSelect('alert.part', 'part') + .where('alert.tenant_id = :tenantId', { tenantId }); + + // Default to active alerts unless specified + if (filters.status) { + queryBuilder.andWhere('alert.status = :status', { status: filters.status }); + } else { + queryBuilder.andWhere('alert.status IN (:...statuses)', { + statuses: [StockAlertStatus.ACTIVE, StockAlertStatus.ACKNOWLEDGED], + }); + } + + if (filters.alertType) { + queryBuilder.andWhere('alert.alert_type = :alertType', { alertType: filters.alertType }); + } + + if (filters.partId) { + queryBuilder.andWhere('alert.part_id = :partId', { partId: filters.partId }); + } + + if (filters.supplierId) { + queryBuilder.andWhere('part.preferred_supplier_id = :supplierId', { supplierId: filters.supplierId }); + } + + if (filters.dateFrom) { + queryBuilder.andWhere('alert.created_at >= :dateFrom', { dateFrom: filters.dateFrom }); + } + + if (filters.dateTo) { + queryBuilder.andWhere('alert.created_at <= :dateTo', { dateTo: filters.dateTo }); + } + + // Sort by severity (out_of_stock first, then low_stock, then overstock) + // and then by created date descending + queryBuilder.orderBy( + `CASE + WHEN alert.alert_type = 'out_of_stock' THEN 1 + WHEN alert.alert_type = 'low_stock' THEN 2 + WHEN alert.alert_type = 'overstock' THEN 3 + ELSE 4 + END`, + 'ASC' + ); + queryBuilder.addOrderBy('alert.created_at', 'DESC'); + + const skip = (pagination.page - 1) * pagination.limit; + + const [data, total] = await queryBuilder + .skip(skip) + .take(pagination.limit) + .getManyAndCount(); + + return { + data, + total, + page: pagination.page, + limit: pagination.limit, + totalPages: Math.ceil(total / pagination.limit), + }; + } + + /** + * Acknowledge an alert + */ + async acknowledgeAlert( + tenantId: string, + alertId: string, + userId: string, + dto: AcknowledgeAlertDto = {} + ): Promise { + const alert = await this.alertRepository.findOne({ + where: { id: alertId, tenantId }, + }); + + if (!alert) { + return null; + } + + if (alert.status === StockAlertStatus.RESOLVED) { + throw new Error('Cannot acknowledge a resolved alert'); + } + + alert.status = StockAlertStatus.ACKNOWLEDGED; + alert.acknowledgedAt = new Date(); + alert.acknowledgedById = userId; + + if (dto.notes) { + alert.notes = alert.notes ? `${alert.notes}\n${dto.notes}` : dto.notes; + } + + return this.alertRepository.save(alert); + } + + /** + * Resolve an alert manually + */ + async resolveAlert( + tenantId: string, + alertId: string, + userId: string, + dto: ResolveAlertDto = {} + ): Promise { + const alert = await this.alertRepository.findOne({ + where: { id: alertId, tenantId }, + }); + + if (!alert) { + return null; + } + + if (alert.status === StockAlertStatus.RESOLVED) { + throw new Error('Alert is already resolved'); + } + + alert.status = StockAlertStatus.RESOLVED; + alert.resolvedAt = new Date(); + alert.resolvedById = userId; + + if (dto.notes) { + alert.notes = alert.notes ? `${alert.notes}\n${dto.notes}` : dto.notes; + } + + return this.alertRepository.save(alert); + } + + /** + * Resolve all active alerts for a part when stock is normalized + */ + async resolveAllForPart( + tenantId: string, + partId: string, + reason?: string + ): Promise { + const activeAlerts = await this.alertRepository.find({ + where: { + tenantId, + partId, + status: In([StockAlertStatus.ACTIVE, StockAlertStatus.ACKNOWLEDGED]), + }, + }); + + if (activeAlerts.length === 0) { + return 0; + } + + const now = new Date(); + const resolveNote = reason || 'Stock normalized - auto-resolved'; + + for (const alert of activeAlerts) { + alert.status = StockAlertStatus.RESOLVED; + alert.resolvedAt = now; + alert.notes = alert.notes ? `${alert.notes}\n${resolveNote}` : resolveNote; + } + + await this.alertRepository.save(activeAlerts); + return activeAlerts.length; + } + + /** + * Get alert statistics for dashboard + */ + async getAlertStats(tenantId: string): Promise { + // Count by type (active and acknowledged only) + const byTypeQuery = await this.alertRepository + .createQueryBuilder('alert') + .select('alert.alert_type', 'alertType') + .addSelect('COUNT(*)', 'count') + .where('alert.tenant_id = :tenantId', { tenantId }) + .andWhere('alert.status IN (:...statuses)', { + statuses: [StockAlertStatus.ACTIVE, StockAlertStatus.ACKNOWLEDGED], + }) + .groupBy('alert.alert_type') + .getRawMany(); + + const byType = { + lowStock: 0, + outOfStock: 0, + overstock: 0, + }; + + for (const row of byTypeQuery) { + switch (row.alertType) { + case StockAlertType.LOW_STOCK: + byType.lowStock = parseInt(row.count, 10); + break; + case StockAlertType.OUT_OF_STOCK: + byType.outOfStock = parseInt(row.count, 10); + break; + case StockAlertType.OVERSTOCK: + byType.overstock = parseInt(row.count, 10); + break; + } + } + + // Count by status + const byStatusQuery = await this.alertRepository + .createQueryBuilder('alert') + .select('alert.status', 'status') + .addSelect('COUNT(*)', 'count') + .where('alert.tenant_id = :tenantId', { tenantId }) + .groupBy('alert.status') + .getRawMany(); + + const byStatus = { + active: 0, + acknowledged: 0, + resolved: 0, + }; + + for (const row of byStatusQuery) { + switch (row.status) { + case StockAlertStatus.ACTIVE: + byStatus.active = parseInt(row.count, 10); + break; + case StockAlertStatus.ACKNOWLEDGED: + byStatus.acknowledged = parseInt(row.count, 10); + break; + case StockAlertStatus.RESOLVED: + byStatus.resolved = parseInt(row.count, 10); + break; + } + } + + // Top 10 parts with recurring alerts (last 30 days) + const thirtyDaysAgo = new Date(); + thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30); + + const topRecurringQuery = await this.alertRepository + .createQueryBuilder('alert') + .innerJoin('alert.part', 'part') + .select('alert.part_id', 'partId') + .addSelect('part.sku', 'partSku') + .addSelect('part.name', 'partName') + .addSelect('COUNT(*)', 'alertCount') + .where('alert.tenant_id = :tenantId', { tenantId }) + .andWhere('alert.created_at >= :thirtyDaysAgo', { thirtyDaysAgo }) + .groupBy('alert.part_id') + .addGroupBy('part.sku') + .addGroupBy('part.name') + .orderBy('COUNT(*)', 'DESC') + .limit(10) + .getRawMany(); + + const topRecurringParts = topRecurringQuery.map(row => ({ + partId: row.partId, + partSku: row.partSku, + partName: row.partName, + alertCount: parseInt(row.alertCount, 10), + })); + + return { + byType, + byStatus, + topRecurringParts, + }; + } + + /** + * Get reorder suggestions based on low stock alerts + */ + async getReorderSuggestions(tenantId: string): Promise<{ + suggestions: ReorderSuggestion[]; + bySupplier: Map; + totalEstimatedCost: number; + }> { + // Get active low_stock and out_of_stock alerts with part details + const alerts = await this.alertRepository + .createQueryBuilder('alert') + .innerJoinAndSelect('alert.part', 'part') + .leftJoin('part.preferredSupplier', 'supplier') + .addSelect(['supplier.id', 'supplier.name']) + .where('alert.tenant_id = :tenantId', { tenantId }) + .andWhere('alert.alert_type IN (:...types)', { + types: [StockAlertType.LOW_STOCK, StockAlertType.OUT_OF_STOCK], + }) + .andWhere('alert.status IN (:...statuses)', { + statuses: [StockAlertStatus.ACTIVE, StockAlertStatus.ACKNOWLEDGED], + }) + .orderBy('alert.alert_type', 'ASC') // out_of_stock comes before low_stock + .addOrderBy('part.name', 'ASC') + .getMany(); + + const suggestions: ReorderSuggestion[] = []; + const bySupplier = new Map(); + let totalEstimatedCost = 0; + + for (const alert of alerts) { + const part = alert.part; + const reorderPoint = part.reorderPoint !== null ? Number(part.reorderPoint) : Number(part.minStock); + const buffer = Math.ceil(reorderPoint * 0.2); // 20% buffer + const suggestedQuantity = Math.max( + 1, + reorderPoint - Number(part.currentStock) + buffer + ); + + const estimatedCost = part.cost !== null && part.cost !== undefined + ? Number(part.cost) * suggestedQuantity + : null; + + if (estimatedCost !== null) { + totalEstimatedCost += estimatedCost; + } + + const suggestion: ReorderSuggestion = { + partId: part.id, + partSku: part.sku, + partName: part.name, + supplierId: part.preferredSupplierId || null, + supplierName: part.preferredSupplier?.name || null, + currentStock: Number(part.currentStock), + minStock: Number(part.minStock), + reorderPoint: part.reorderPoint !== null ? Number(part.reorderPoint) : null, + suggestedQuantity, + estimatedCost, + alertId: alert.id, + alertType: alert.alertType, + }; + + suggestions.push(suggestion); + + // Group by supplier + const supplierKey = part.preferredSupplierId || 'NO_SUPPLIER'; + if (!bySupplier.has(supplierKey)) { + bySupplier.set(supplierKey, []); + } + bySupplier.get(supplierKey)!.push(suggestion); + } + + return { + suggestions, + bySupplier, + totalEstimatedCost, + }; + } + + /** + * Prepare notification data for alerts + */ + async sendAlertNotifications( + tenantId: string, + alertIds: string[] + ): Promise { + if (alertIds.length === 0) { + return { + critical: [], + warning: [], + info: [], + summary: { + totalAlerts: 0, + criticalCount: 0, + warningCount: 0, + infoCount: 0, + }, + }; + } + + const alerts = await this.alertRepository + .createQueryBuilder('alert') + .innerJoinAndSelect('alert.part', 'part') + .where('alert.tenant_id = :tenantId', { tenantId }) + .andWhere('alert.id IN (:...alertIds)', { alertIds }) + .getMany(); + + const critical: NotificationData[] = []; + const warning: NotificationData[] = []; + const info: NotificationData[] = []; + + for (const alert of alerts) { + const part = alert.part; + let severity: 'critical' | 'warning' | 'info'; + let message: string; + + switch (alert.alertType) { + case StockAlertType.OUT_OF_STOCK: + severity = 'critical'; + message = `URGENTE: ${part.name} (${part.sku}) está agotado. Stock actual: 0`; + break; + case StockAlertType.LOW_STOCK: + severity = 'warning'; + message = `Alerta: ${part.name} (${part.sku}) tiene stock bajo. Actual: ${alert.currentStock}, Mínimo: ${alert.threshold}`; + break; + case StockAlertType.OVERSTOCK: + severity = 'info'; + message = `Aviso: ${part.name} (${part.sku}) tiene exceso de stock. Actual: ${alert.currentStock}, Máximo: ${alert.threshold}`; + break; + default: + severity = 'info'; + message = `Alerta de inventario para ${part.name} (${part.sku})`; + } + + const notificationData: NotificationData = { + alertId: alert.id, + alertType: alert.alertType, + severity, + partSku: part.sku, + partName: part.name, + currentStock: Number(alert.currentStock), + threshold: Number(alert.threshold), + message, + createdAt: alert.createdAt, + }; + + switch (severity) { + case 'critical': + critical.push(notificationData); + break; + case 'warning': + warning.push(notificationData); + break; + case 'info': + info.push(notificationData); + break; + } + } + + return { + critical, + warning, + info, + summary: { + totalAlerts: alerts.length, + criticalCount: critical.length, + warningCount: warning.length, + infoCount: info.length, + }, + }; + } + + /** + * Clean up old resolved alerts + */ + async cleanupOldAlerts( + tenantId: string, + daysOld: number + ): Promise<{ deleted: number; archived: number }> { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - daysOld); + + // Find old resolved alerts + const oldAlerts = await this.alertRepository.find({ + where: { + tenantId, + status: StockAlertStatus.RESOLVED, + resolvedAt: LessThanOrEqual(cutoffDate), + }, + }); + + if (oldAlerts.length === 0) { + return { deleted: 0, archived: 0 }; + } + + // In a production system, you might want to archive to a separate table + // For now, we'll just delete and return the count + const alertIds = oldAlerts.map(a => a.id); + + await this.alertRepository.delete({ + id: In(alertIds), + }); + + return { + deleted: oldAlerts.length, + archived: 0, // Would be populated if archiving to separate table + }; + } + + /** + * Get alert by ID + */ + async findById(tenantId: string, id: string): Promise { + return this.alertRepository.findOne({ + where: { id, tenantId }, + relations: ['part'], + }); + } + + /** + * Get alerts for a specific part + */ + async findByPartId( + tenantId: string, + partId: string, + includeResolved = false + ): Promise { + const whereCondition: any = { + tenantId, + partId, + }; + + if (!includeResolved) { + whereCondition.status = In([StockAlertStatus.ACTIVE, StockAlertStatus.ACKNOWLEDGED]); + } + + return this.alertRepository.find({ + where: whereCondition, + order: { createdAt: 'DESC' }, + relations: ['part'], + }); + } + + // Private helper methods + + /** + * Create an alert only if one doesn't already exist for the same part/type + */ + private async createAlertIfNotExists( + tenantId: string, + partId: string, + alertType: StockAlertType, + currentStock: number, + threshold: number + ): Promise { + // Check if an active alert already exists + const existing = await this.alertRepository.findOne({ + where: { + tenantId, + partId, + alertType, + status: In([StockAlertStatus.ACTIVE, StockAlertStatus.ACKNOWLEDGED]), + }, + }); + + if (existing) { + return false; + } + + await this.createAlert(tenantId, partId, alertType, currentStock, threshold); + return true; + } + + /** + * Create a new alert + */ + private async createAlert( + tenantId: string, + partId: string, + alertType: StockAlertType, + currentStock: number, + threshold: number + ): Promise { + const alert = this.alertRepository.create({ + tenantId, + partId, + alertType, + status: StockAlertStatus.ACTIVE, + currentStock, + threshold, + }); + + return this.alertRepository.save(alert); + } +} diff --git a/src/modules/parts-management/services/warehouse-entry.service.ts b/src/modules/parts-management/services/warehouse-entry.service.ts new file mode 100644 index 0000000..a964403 --- /dev/null +++ b/src/modules/parts-management/services/warehouse-entry.service.ts @@ -0,0 +1,900 @@ +/** + * Warehouse Entry Service + * Mecánicas Diesel - ERP Suite + * + * Handles receiving parts into the warehouse/inventory. + * Manages purchase entries, returns, transfers, and movement history. + */ + +import { Repository, DataSource, Between, In, EntityManager } from 'typeorm'; +import { Part } from '../entities/part.entity'; +import { Supplier } from '../entities/supplier.entity'; +import { InventoryMovement, MovementType, MovementReferenceType } from '../entities/inventory-movement.entity'; + +// ============================================================================ +// DTOs +// ============================================================================ + +export interface CreatePurchaseEntryDto { + partId: string; + quantity: number; + unitCost: number; + invoiceNumber?: string; + notes?: string; +} + +export interface BulkEntryDto { + entries: CreatePurchaseEntryDto[]; +} + +export interface BulkEntryResult { + successful: Array<{ + partId: string; + movementId: string; + quantity: number; + }>; + failed: Array<{ + partId: string; + error: string; + }>; + totalProcessed: number; + totalSuccess: number; + totalFailed: number; +} + +export interface ReceiveFromSupplierItem { + partId: string; + quantity: number; + unitCost: number; + invoiceNumber?: string; +} + +export interface ReceiveFromSupplierResult { + supplierId: string; + supplierName: string; + totalItems: number; + totalQuantity: number; + totalValue: number; + movements: InventoryMovement[]; + receivedAt: Date; +} + +export interface ReturnEntryDto { + orderId: string; + partId: string; + quantity: number; + reason: string; +} + +export interface TransferDto { + partId: string; + fromLocationId: string; + toLocationId: string; + quantity: number; + notes?: string; +} + +export interface TransferResult { + partId: string; + fromLocationId: string; + toLocationId: string; + quantity: number; + outMovementId: string; + inMovementId: string; +} + +export interface MovementHistoryFilters { + startDate?: Date; + endDate?: Date; + movementTypes?: MovementType[]; + page?: number; + limit?: number; +} + +export interface MovementHistoryResult { + data: InventoryMovement[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +export interface DailyMovementsSummary { + date: Date; + totalEntries: number; + totalExits: number; + netChange: number; + byType: Record; + totalValue: number; +} + +// ============================================================================ +// Service +// ============================================================================ + +export class WarehouseEntryService { + private partRepository: Repository; + private supplierRepository: Repository; + private movementRepository: Repository; + private dataSource: DataSource; + + constructor(dataSource: DataSource) { + this.dataSource = dataSource; + this.partRepository = dataSource.getRepository(Part); + this.supplierRepository = dataSource.getRepository(Supplier); + this.movementRepository = dataSource.getRepository(InventoryMovement); + } + + /** + * Create a purchase entry - receive parts from purchase + * Updates part stock and calculates weighted average cost + */ + async createPurchaseEntry( + tenantId: string, + dto: CreatePurchaseEntryDto, + performedById: string + ): Promise { + return this.dataSource.transaction(async (manager) => { + const partRepo = manager.getRepository(Part); + const movementRepo = manager.getRepository(InventoryMovement); + + // Validate part exists + const part = await partRepo.findOne({ + where: { id: dto.partId, tenantId }, + }); + + if (!part) { + throw new Error(`Part with ID ${dto.partId} not found`); + } + + if (dto.quantity <= 0) { + throw new Error('Quantity must be greater than zero'); + } + + if (dto.unitCost < 0) { + throw new Error('Unit cost cannot be negative'); + } + + const previousStock = part.currentStock; + const newStock = previousStock + dto.quantity; + const totalCost = dto.quantity * dto.unitCost; + + // Calculate weighted average cost + const newAverageCost = this.calculateWeightedAverageCost( + previousStock, + part.cost || 0, + dto.quantity, + dto.unitCost + ); + + // Create movement record + const movement = movementRepo.create({ + tenantId, + partId: dto.partId, + movementType: MovementType.PURCHASE, + quantity: dto.quantity, + unitCost: dto.unitCost, + totalCost, + previousStock, + newStock, + referenceType: MovementReferenceType.PURCHASE_ORDER, + referenceId: undefined, + notes: dto.invoiceNumber + ? `Invoice: ${dto.invoiceNumber}${dto.notes ? ` - ${dto.notes}` : ''}` + : dto.notes, + performedById, + performedAt: new Date(), + }); + + await movementRepo.save(movement); + + // Update part stock and cost + part.currentStock = newStock; + part.cost = newAverageCost; + await partRepo.save(part); + + return movement; + }); + } + + /** + * Create bulk entries - receive multiple parts in a single transaction + */ + async createBulkEntry( + tenantId: string, + dto: BulkEntryDto, + performedById: string + ): Promise { + const result: BulkEntryResult = { + successful: [], + failed: [], + totalProcessed: dto.entries.length, + totalSuccess: 0, + totalFailed: 0, + }; + + return this.dataSource.transaction(async (manager) => { + for (const entry of dto.entries) { + try { + const movement = await this.createPurchaseEntryWithManager( + manager, + tenantId, + entry, + performedById + ); + + result.successful.push({ + partId: entry.partId, + movementId: movement.id, + quantity: entry.quantity, + }); + result.totalSuccess++; + } catch (error) { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + result.failed.push({ + partId: entry.partId, + error: errorMessage, + }); + result.totalFailed++; + } + } + + return result; + }); + } + + /** + * Receive parts from a specific supplier + * Full receiving workflow with supplier validation + */ + async receiveFromSupplier( + tenantId: string, + supplierId: string, + items: ReceiveFromSupplierItem[], + performedById: string + ): Promise { + return this.dataSource.transaction(async (manager) => { + const supplierRepo = manager.getRepository(Supplier); + + // Validate supplier exists + const supplier = await supplierRepo.findOne({ + where: { id: supplierId, tenantId }, + }); + + if (!supplier) { + throw new Error(`Supplier with ID ${supplierId} not found`); + } + + if (!supplier.isActive) { + throw new Error(`Supplier ${supplier.name} is not active`); + } + + const movements: InventoryMovement[] = []; + let totalQuantity = 0; + let totalValue = 0; + + // Process each item + for (const item of items) { + const movement = await this.createPurchaseEntryWithManager( + manager, + tenantId, + { + partId: item.partId, + quantity: item.quantity, + unitCost: item.unitCost, + invoiceNumber: item.invoiceNumber, + notes: `Received from supplier: ${supplier.name}`, + }, + performedById + ); + + movements.push(movement); + totalQuantity += item.quantity; + totalValue += item.quantity * item.unitCost; + } + + const receivedAt = new Date(); + + return { + supplierId, + supplierName: supplier.name, + totalItems: items.length, + totalQuantity, + totalValue, + movements, + receivedAt, + }; + }); + } + + /** + * Create return entry - return part to stock from service order + */ + async createReturnEntry( + tenantId: string, + dto: ReturnEntryDto, + performedById: string + ): Promise { + return this.dataSource.transaction(async (manager) => { + const partRepo = manager.getRepository(Part); + const movementRepo = manager.getRepository(InventoryMovement); + + // Validate part exists + const part = await partRepo.findOne({ + where: { id: dto.partId, tenantId }, + }); + + if (!part) { + throw new Error(`Part with ID ${dto.partId} not found`); + } + + if (dto.quantity <= 0) { + throw new Error('Return quantity must be greater than zero'); + } + + const previousStock = part.currentStock; + const newStock = previousStock + dto.quantity; + + // Create movement record + const movement = movementRepo.create({ + tenantId, + partId: dto.partId, + movementType: MovementType.RETURN, + quantity: dto.quantity, + unitCost: part.cost, + totalCost: dto.quantity * (part.cost || 0), + previousStock, + newStock, + referenceType: MovementReferenceType.RETURN, + referenceId: dto.orderId, + notes: `Return reason: ${dto.reason}`, + performedById, + performedAt: new Date(), + }); + + await movementRepo.save(movement); + + // Update part stock (no cost recalculation for returns) + part.currentStock = newStock; + await partRepo.save(part); + + return movement; + }); + } + + /** + * Create transfer between warehouse locations + * Creates two movements: out from source, in to destination + */ + async createTransfer( + tenantId: string, + dto: TransferDto, + performedById: string + ): Promise { + return this.dataSource.transaction(async (manager) => { + const partRepo = manager.getRepository(Part); + const movementRepo = manager.getRepository(InventoryMovement); + + // Validate part exists + const part = await partRepo.findOne({ + where: { id: dto.partId, tenantId }, + }); + + if (!part) { + throw new Error(`Part with ID ${dto.partId} not found`); + } + + if (dto.quantity <= 0) { + throw new Error('Transfer quantity must be greater than zero'); + } + + if (dto.fromLocationId === dto.toLocationId) { + throw new Error('Source and destination locations cannot be the same'); + } + + // For transfers, stock remains the same (internal movement) + // We create two movements for traceability + const currentStock = part.currentStock; + const performedAt = new Date(); + + // Create OUT movement + const outMovement = movementRepo.create({ + tenantId, + partId: dto.partId, + movementType: MovementType.TRANSFER, + quantity: dto.quantity, + unitCost: part.cost, + totalCost: dto.quantity * (part.cost || 0), + previousStock: currentStock, + newStock: currentStock, // Stock doesn't change for internal transfers + referenceType: MovementReferenceType.MANUAL, + referenceId: dto.fromLocationId, + notes: `Transfer OUT to location ${dto.toLocationId}${dto.notes ? ` - ${dto.notes}` : ''}`, + performedById, + performedAt, + }); + + await movementRepo.save(outMovement); + + // Create IN movement + const inMovement = movementRepo.create({ + tenantId, + partId: dto.partId, + movementType: MovementType.TRANSFER, + quantity: dto.quantity, + unitCost: part.cost, + totalCost: dto.quantity * (part.cost || 0), + previousStock: currentStock, + newStock: currentStock, + referenceType: MovementReferenceType.MANUAL, + referenceId: dto.toLocationId, + notes: `Transfer IN from location ${dto.fromLocationId}${dto.notes ? ` - ${dto.notes}` : ''}`, + performedById, + performedAt, + }); + + await movementRepo.save(inMovement); + + // Update part location if it has a location assigned + if (part.locationId === dto.fromLocationId) { + part.locationId = dto.toLocationId; + await partRepo.save(part); + } + + return { + partId: dto.partId, + fromLocationId: dto.fromLocationId, + toLocationId: dto.toLocationId, + quantity: dto.quantity, + outMovementId: outMovement.id, + inMovementId: inMovement.id, + }; + }); + } + + /** + * Get movement history for a specific part + */ + async getMovementHistory( + tenantId: string, + partId: string, + filters: MovementHistoryFilters = {} + ): Promise { + const page = filters.page || 1; + const limit = filters.limit || 20; + const skip = (page - 1) * limit; + + const queryBuilder = this.movementRepository + .createQueryBuilder('movement') + .where('movement.tenant_id = :tenantId', { tenantId }) + .andWhere('movement.part_id = :partId', { partId }); + + // Apply date range filter + if (filters.startDate && filters.endDate) { + queryBuilder.andWhere( + 'movement.performed_at BETWEEN :startDate AND :endDate', + { startDate: filters.startDate, endDate: filters.endDate } + ); + } else if (filters.startDate) { + queryBuilder.andWhere('movement.performed_at >= :startDate', { + startDate: filters.startDate, + }); + } else if (filters.endDate) { + queryBuilder.andWhere('movement.performed_at <= :endDate', { + endDate: filters.endDate, + }); + } + + // Apply movement type filter + if (filters.movementTypes && filters.movementTypes.length > 0) { + queryBuilder.andWhere('movement.movement_type IN (:...types)', { + types: filters.movementTypes, + }); + } + + const [data, total] = await queryBuilder + .orderBy('movement.performed_at', 'DESC') + .skip(skip) + .take(limit) + .getManyAndCount(); + + return { + data, + total, + page, + limit, + totalPages: Math.ceil(total / limit), + }; + } + + /** + * Get all movements related to a specific service order + */ + async getMovementsByOrder( + tenantId: string, + orderId: string + ): Promise { + return this.movementRepository.find({ + where: { + tenantId, + referenceId: orderId, + referenceType: In([ + MovementReferenceType.SERVICE_ORDER, + MovementReferenceType.RETURN, + ]), + }, + order: { performedAt: 'DESC' }, + relations: ['part'], + }); + } + + /** + * Recalculate weighted average cost for a part + * Based on all purchase movements + */ + async calculateAverageCost(tenantId: string, partId: string): 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 }, + }); + + if (!part) { + throw new Error(`Part with ID ${partId} not found`); + } + + // Get all purchase movements + const purchaseMovements = await movementRepo.find({ + where: { + tenantId, + partId, + movementType: MovementType.PURCHASE, + }, + order: { performedAt: 'ASC' }, + }); + + if (purchaseMovements.length === 0) { + return part.cost || 0; + } + + // Calculate weighted average using FIFO-like approach + let totalQuantity = 0; + let totalValue = 0; + + for (const movement of purchaseMovements) { + totalQuantity += Number(movement.quantity); + totalValue += Number(movement.totalCost || 0); + } + + const averageCost = totalQuantity > 0 ? totalValue / totalQuantity : 0; + + // Update part cost + part.cost = averageCost; + await partRepo.save(part); + + return averageCost; + }); + } + + /** + * Get daily movements summary for a specific date + */ + async getDailyMovementsSummary( + tenantId: string, + date: Date + ): Promise { + const startOfDay = new Date(date); + startOfDay.setHours(0, 0, 0, 0); + + const endOfDay = new Date(date); + endOfDay.setHours(23, 59, 59, 999); + + const movements = await this.movementRepository.find({ + where: { + tenantId, + performedAt: Between(startOfDay, endOfDay), + }, + }); + + const inboundTypes = [ + MovementType.PURCHASE, + MovementType.ADJUSTMENT_IN, + MovementType.RETURN, + ]; + + const outboundTypes = [ + MovementType.CONSUMPTION, + MovementType.ADJUSTMENT_OUT, + ]; + + let totalEntries = 0; + let totalExits = 0; + let totalValue = 0; + + const byType: Record = { + [MovementType.PURCHASE]: { count: 0, quantity: 0, value: 0 }, + [MovementType.CONSUMPTION]: { count: 0, quantity: 0, value: 0 }, + [MovementType.ADJUSTMENT_IN]: { count: 0, quantity: 0, value: 0 }, + [MovementType.ADJUSTMENT_OUT]: { count: 0, quantity: 0, value: 0 }, + [MovementType.RETURN]: { count: 0, quantity: 0, value: 0 }, + [MovementType.TRANSFER]: { count: 0, quantity: 0, value: 0 }, + }; + + for (const movement of movements) { + const quantity = Number(movement.quantity); + const value = Number(movement.totalCost || 0); + + // Update type statistics + byType[movement.movementType].count++; + byType[movement.movementType].quantity += quantity; + byType[movement.movementType].value += value; + + // Calculate totals + if (inboundTypes.includes(movement.movementType)) { + totalEntries += quantity; + totalValue += value; + } else if (outboundTypes.includes(movement.movementType)) { + totalExits += quantity; + totalValue -= value; + } + // Transfers don't affect totals + } + + return { + date: startOfDay, + totalEntries, + totalExits, + netChange: totalEntries - totalExits, + byType, + totalValue, + }; + } + + /** + * Create adjustment entry (manual stock correction) + */ + async createAdjustmentEntry( + tenantId: string, + partId: string, + quantity: number, + reason: string, + performedById: string + ): 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 }, + }); + + if (!part) { + throw new Error(`Part with ID ${partId} not found`); + } + + const previousStock = part.currentStock; + const newStock = previousStock + quantity; + + if (newStock < 0) { + throw new Error('Adjustment would result in negative stock'); + } + + const movementType = quantity >= 0 + ? MovementType.ADJUSTMENT_IN + : MovementType.ADJUSTMENT_OUT; + + const movement = movementRepo.create({ + tenantId, + partId, + movementType, + quantity: Math.abs(quantity), + unitCost: part.cost, + totalCost: Math.abs(quantity) * (part.cost || 0), + previousStock, + newStock, + referenceType: MovementReferenceType.ADJUSTMENT, + notes: reason, + performedById, + performedAt: new Date(), + }); + + await movementRepo.save(movement); + + part.currentStock = newStock; + await partRepo.save(part); + + return movement; + }); + } + + /** + * Create consumption entry (part used in service order) + */ + async createConsumptionEntry( + tenantId: string, + partId: string, + quantity: number, + orderId: string, + performedById: string, + notes?: string + ): 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 }, + }); + + if (!part) { + throw new Error(`Part with ID ${partId} not found`); + } + + if (quantity <= 0) { + throw new Error('Consumption quantity must be greater than zero'); + } + + const previousStock = part.currentStock; + const newStock = previousStock - quantity; + + if (newStock < 0) { + throw new Error(`Insufficient stock. Available: ${previousStock}, Requested: ${quantity}`); + } + + const movement = movementRepo.create({ + tenantId, + partId, + movementType: MovementType.CONSUMPTION, + quantity, + unitCost: part.cost, + totalCost: quantity * (part.cost || 0), + previousStock, + newStock, + referenceType: MovementReferenceType.SERVICE_ORDER, + referenceId: orderId, + notes, + performedById, + performedAt: new Date(), + }); + + await movementRepo.save(movement); + + part.currentStock = newStock; + await partRepo.save(part); + + return movement; + }); + } + + /** + * Get stock value report for all parts + */ + async getStockValueReport(tenantId: string): Promise<{ + totalParts: number; + totalQuantity: number; + totalCostValue: number; + totalSaleValue: number; + averageMargin: number; + }> { + const result = await this.partRepository + .createQueryBuilder('part') + .select('COUNT(part.id)', 'totalParts') + .addSelect('SUM(part.current_stock)', 'totalQuantity') + .addSelect('SUM(part.current_stock * COALESCE(part.cost, 0))', 'totalCostValue') + .addSelect('SUM(part.current_stock * part.price)', 'totalSaleValue') + .where('part.tenant_id = :tenantId', { tenantId }) + .andWhere('part.is_active = true') + .getRawOne(); + + const totalCostValue = parseFloat(result?.totalCostValue) || 0; + const totalSaleValue = parseFloat(result?.totalSaleValue) || 0; + + return { + totalParts: parseInt(result?.totalParts, 10) || 0, + totalQuantity: parseFloat(result?.totalQuantity) || 0, + totalCostValue, + totalSaleValue, + averageMargin: totalCostValue > 0 + ? ((totalSaleValue - totalCostValue) / totalCostValue) * 100 + : 0, + }; + } + + // ============================================================================ + // Private Helper Methods + // ============================================================================ + + /** + * Calculate weighted average cost when receiving new inventory + */ + private calculateWeightedAverageCost( + existingQuantity: number, + existingCost: number, + newQuantity: number, + newCost: number + ): number { + const totalQuantity = existingQuantity + newQuantity; + + if (totalQuantity === 0) { + return 0; + } + + const existingValue = existingQuantity * existingCost; + const newValue = newQuantity * newCost; + const totalValue = existingValue + newValue; + + return totalValue / totalQuantity; + } + + /** + * Internal method to create purchase entry with a provided EntityManager + */ + private async createPurchaseEntryWithManager( + manager: EntityManager, + tenantId: string, + dto: CreatePurchaseEntryDto, + performedById: string + ): Promise { + const partRepo = manager.getRepository(Part); + const movementRepo = manager.getRepository(InventoryMovement); + + // Validate part exists + const part = await partRepo.findOne({ + where: { id: dto.partId, tenantId }, + }); + + if (!part) { + throw new Error(`Part with ID ${dto.partId} not found`); + } + + if (dto.quantity <= 0) { + throw new Error('Quantity must be greater than zero'); + } + + if (dto.unitCost < 0) { + throw new Error('Unit cost cannot be negative'); + } + + const previousStock = part.currentStock; + const newStock = previousStock + dto.quantity; + const totalCost = dto.quantity * dto.unitCost; + + // Calculate weighted average cost + const newAverageCost = this.calculateWeightedAverageCost( + previousStock, + part.cost || 0, + dto.quantity, + dto.unitCost + ); + + // Create movement record + const movement = movementRepo.create({ + tenantId, + partId: dto.partId, + movementType: MovementType.PURCHASE, + quantity: dto.quantity, + unitCost: dto.unitCost, + totalCost, + previousStock, + newStock, + referenceType: MovementReferenceType.PURCHASE_ORDER, + referenceId: undefined, + notes: dto.invoiceNumber + ? `Invoice: ${dto.invoiceNumber}${dto.notes ? ` - ${dto.notes}` : ''}` + : dto.notes, + performedById, + performedAt: new Date(), + }); + + await movementRepo.save(movement); + + // Update part stock and cost + part.currentStock = newStock; + part.cost = newAverageCost; + await partRepo.save(part); + + return movement; + } +} diff --git a/src/modules/service-management/services/work-execution.service.ts b/src/modules/service-management/services/work-execution.service.ts new file mode 100644 index 0000000..1dd4d4b --- /dev/null +++ b/src/modules/service-management/services/work-execution.service.ts @@ -0,0 +1,1101 @@ +/** + * Work Execution Service + * Mecanicas Diesel - ERP Suite + * + * Orchestrates work execution on service orders, tracking labor and parts consumption. + * Manages the full lifecycle of work items from assignment to completion. + */ + +import { Repository, DataSource } from 'typeorm'; +import { + ServiceOrder, + ServiceOrderStatus, +} from '../entities/service-order.entity'; +import { OrderItem, OrderItemType, OrderItemStatus } from '../entities/order-item.entity'; +import { Part } from '../../parts-management/entities/part.entity'; + +// ============================================ +// DTOs +// ============================================ + +/** + * DTO for starting work on an order + */ +export interface StartWorkDto { + orderId: string; + notes?: string; +} + +/** + * DTO for assigning a mechanic to an order item + */ +export interface AssignMechanicDto { + orderId: string; + itemId: string; + mechanicId: string; +} + +/** + * DTO for recording labor time + */ +export interface RecordLaborDto { + orderId: string; + itemId: string; + hours: number; + notes?: string; +} + +/** + * DTO for adding a part to an order during execution + */ +export interface AddPartDto { + partId: string; + quantity: number; + description?: string; + unitPrice?: number; + discountPct?: number; + notes?: string; +} + +/** + * DTO for requesting additional parts + */ +export interface RequestPartsDto { + items: Array<{ + partId: string; + quantity: number; + priority?: 'low' | 'normal' | 'high' | 'urgent'; + notes?: string; + }>; +} + +/** + * Result of parts request + */ +export interface PartsRequestResult { + reserved: Array<{ + partId: string; + partSku: string; + partName: string; + quantityRequested: number; + quantityReserved: number; + }>; + pending: Array<{ + partId: string; + partSku: string; + partName: string; + quantityRequested: number; + availableStock: number; + shortfall: number; + priority: string; + }>; + alerts: string[]; +} + +/** + * Work session for tracking continuous work periods + */ +export interface WorkSession { + sessionId: string; + startedAt: Date; + endedAt?: Date; + mechanicId?: string; + notes?: string; +} + +/** + * Order work status summary + */ +export interface OrderWorkStatus { + orderId: string; + orderNumber: string; + status: ServiceOrderStatus; + startedAt: Date | null; + completedAt: Date | null; + items: { + total: number; + pending: number; + inProgress: number; + completed: number; + }; + labor: { + estimatedHours: number; + actualHours: number; + efficiency: number; + }; + parts: { + total: number; + reserved: number; + consumed: number; + pending: number; + }; + mechanics: Array<{ + id: string; + itemsAssigned: number; + hoursLogged: number; + }>; +} + +/** + * WorkExecutionService + * + * Orchestrates work execution on service orders including: + * - Starting and pausing work + * - Assigning mechanics to work items + * - Recording labor time + * - Managing parts consumption during work + * - Tracking work completion + */ +export class WorkExecutionService { + private orderRepository: Repository; + private itemRepository: Repository; + private partRepository: Repository; + + constructor(private dataSource: DataSource) { + this.orderRepository = dataSource.getRepository(ServiceOrder); + this.itemRepository = dataSource.getRepository(OrderItem); + this.partRepository = dataSource.getRepository(Part); + } + + // ============================================ + // WORK LIFECYCLE METHODS + // ============================================ + + /** + * Start work on an order + * + * Validates order is in 'approved' or 'in_progress' status. + * Updates order status to 'in_progress' if not already. + * Sets startedAt timestamp. + * Creates initial work session. + * + * @param tenantId - Tenant identifier + * @param orderId - Order to start work on + * @param userId - User starting the work (optional) + * @returns Updated service order + * @throws Error if order not found or invalid status + */ + async startWorkOnOrder( + tenantId: string, + orderId: string, + userId?: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + const validStatuses = [ + ServiceOrderStatus.APPROVED, + ServiceOrderStatus.IN_PROGRESS, + ]; + + if (!validStatuses.includes(order.status)) { + throw new Error( + `Cannot start work on order in status '${order.status}'. ` + + `Order must be in status: ${validStatuses.join(' or ')}` + ); + } + + const now = new Date(); + + if (order.status !== ServiceOrderStatus.IN_PROGRESS) { + order.status = ServiceOrderStatus.IN_PROGRESS; + } + + if (!order.startedAt) { + order.startedAt = now; + } + + const existingNotes = order.internalNotes || ''; + const workSession: WorkSession = { + sessionId: this.generateSessionId(), + startedAt: now, + mechanicId: userId, + notes: 'Work started', + }; + + const sessionLog = `\n[${now.toISOString()}] Work session started by ${userId || 'unknown'}`; + order.internalNotes = existingNotes + sessionLog; + + const savedOrder = await this.orderRepository.save(order); + + const pendingItems = await this.itemRepository.find({ + where: { orderId, status: OrderItemStatus.PENDING }, + }); + + if (pendingItems.length > 0 && userId) { + const firstServiceItem = pendingItems.find( + item => item.itemType === OrderItemType.SERVICE + ); + + if (firstServiceItem) { + firstServiceItem.status = OrderItemStatus.IN_PROGRESS; + firstServiceItem.performedBy = userId; + await this.itemRepository.save(firstServiceItem); + } + } + + return savedOrder; + } + + /** + * Assign mechanic to an order item + * + * @param tenantId - Tenant identifier + * @param dto - Assignment data + * @returns Updated order item + * @throws Error if order or item not found + */ + async assignMechanic( + tenantId: string, + dto: AssignMechanicDto + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: dto.orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${dto.orderId}`); + } + + const item = await this.itemRepository.findOne({ + where: { id: dto.itemId, orderId: dto.orderId }, + }); + + if (!item) { + throw new Error(`Order item not found: ${dto.itemId} in order ${dto.orderId}`); + } + + item.performedBy = dto.mechanicId; + + if (item.status === OrderItemStatus.PENDING) { + item.status = OrderItemStatus.IN_PROGRESS; + } + + return this.itemRepository.save(item); + } + + /** + * Record labor time for an order item + * + * Updates actualHours field and calculates labor cost. + * + * @param tenantId - Tenant identifier + * @param dto - Labor record data + * @returns Updated order item + * @throws Error if order or item not found + */ + async recordLaborTime( + tenantId: string, + dto: RecordLaborDto + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: dto.orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${dto.orderId}`); + } + + const item = await this.itemRepository.findOne({ + where: { id: dto.itemId, orderId: dto.orderId }, + }); + + if (!item) { + throw new Error(`Order item not found: ${dto.itemId} in order ${dto.orderId}`); + } + + if (item.itemType !== OrderItemType.SERVICE) { + throw new Error('Labor time can only be recorded for service items'); + } + + const currentHours = Number(item.actualHours) || 0; + item.actualHours = currentHours + dto.hours; + + if (dto.notes) { + const existingNotes = item.notes || ''; + const now = new Date(); + item.notes = existingNotes + + `\n[${now.toISOString()}] Labor: ${dto.hours}h - ${dto.notes}`; + } + + const savedItem = await this.itemRepository.save(item); + + await this.recalculateTotals(dto.orderId); + + return savedItem; + } + + /** + * Complete a work item + * + * Sets item status to 'completed'. + * If item has partId, triggers parts consumption. + * Recalculates order totals. + * + * @param tenantId - Tenant identifier + * @param orderId - Order ID + * @param itemId - Item to complete + * @param notes - Optional completion notes + * @returns Updated order item + * @throws Error if order or item not found + */ + async completeWorkItem( + tenantId: string, + orderId: string, + itemId: string, + notes?: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + const item = await this.itemRepository.findOne({ + where: { id: itemId, orderId }, + }); + + if (!item) { + throw new Error(`Order item not found: ${itemId} in order ${orderId}`); + } + + item.status = OrderItemStatus.COMPLETED; + item.completedAt = new Date(); + + if (notes) { + const existingNotes = item.notes || ''; + item.notes = existingNotes + `\n[Completed] ${notes}`; + } + + if (item.partId && item.itemType === OrderItemType.PART) { + await this.consumePartStock(tenantId, item.partId, Number(item.quantity)); + } + + const savedItem = await this.itemRepository.save(item); + + await this.recalculateTotals(orderId); + + return savedItem; + } + + /** + * Add a part to an order during execution + * + * Creates OrderItem with type 'part'. + * Reserves stock immediately. + * Recalculates order totals. + * + * @param tenantId - Tenant identifier + * @param orderId - Order to add part to + * @param dto - Part data + * @returns Created order item + * @throws Error if order not found or insufficient stock + */ + async addPartToOrder( + tenantId: string, + orderId: string, + dto: AddPartDto + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + const part = await this.partRepository.findOne({ + where: { id: dto.partId, tenantId }, + }); + + if (!part) { + throw new Error(`Part not found: ${dto.partId}`); + } + + const availableStock = part.currentStock - part.reservedStock; + if (dto.quantity > availableStock) { + throw new Error( + `Insufficient stock for part ${part.sku}. ` + + `Available: ${availableStock}, Requested: ${dto.quantity}` + ); + } + + await this.reservePartStock(tenantId, dto.partId, dto.quantity); + + const unitPrice = dto.unitPrice !== undefined ? dto.unitPrice : Number(part.price); + const discountPct = dto.discountPct || 0; + const subtotal = dto.quantity * unitPrice * (1 - discountPct / 100); + + const description = dto.description || `${part.name} (${part.sku})`; + + const item = this.itemRepository.create({ + orderId, + itemType: OrderItemType.PART, + partId: dto.partId, + description, + quantity: dto.quantity, + unitPrice, + discountPct, + subtotal, + status: OrderItemStatus.PENDING, + notes: dto.notes, + }); + + const savedItem = await this.itemRepository.save(item); + + await this.recalculateTotals(orderId); + + return savedItem; + } + + /** + * Request additional parts for an order + * + * For each item: checks availability, reserves if available. + * Returns list of what was reserved and what's pending. + * Creates alerts for unavailable parts. + * + * @param tenantId - Tenant identifier + * @param orderId - Order to request parts for + * @param dto - Parts request data + * @returns Parts request result with reserved, pending, and alerts + */ + async requestAdditionalParts( + tenantId: string, + orderId: string, + dto: RequestPartsDto + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + const result: PartsRequestResult = { + reserved: [], + pending: [], + alerts: [], + }; + + for (const requestItem of dto.items) { + const part = await this.partRepository.findOne({ + where: { id: requestItem.partId, tenantId }, + }); + + if (!part) { + result.alerts.push(`Part not found: ${requestItem.partId}`); + continue; + } + + const availableStock = part.currentStock - part.reservedStock; + + if (availableStock >= requestItem.quantity) { + await this.reservePartStock(tenantId, requestItem.partId, requestItem.quantity); + + result.reserved.push({ + partId: part.id, + partSku: part.sku, + partName: part.name, + quantityRequested: requestItem.quantity, + quantityReserved: requestItem.quantity, + }); + } else if (availableStock > 0) { + await this.reservePartStock(tenantId, requestItem.partId, availableStock); + + const shortfall = requestItem.quantity - availableStock; + + result.reserved.push({ + partId: part.id, + partSku: part.sku, + partName: part.name, + quantityRequested: requestItem.quantity, + quantityReserved: availableStock, + }); + + result.pending.push({ + partId: part.id, + partSku: part.sku, + partName: part.name, + quantityRequested: requestItem.quantity, + availableStock: 0, + shortfall, + priority: requestItem.priority || 'normal', + }); + + result.alerts.push( + `Partial reservation for ${part.sku}: ${availableStock} of ${requestItem.quantity} units. ` + + `Shortfall: ${shortfall} units.` + ); + } else { + result.pending.push({ + partId: part.id, + partSku: part.sku, + partName: part.name, + quantityRequested: requestItem.quantity, + availableStock: 0, + shortfall: requestItem.quantity, + priority: requestItem.priority || 'normal', + }); + + const priorityText = requestItem.priority === 'urgent' ? '[URGENT] ' : + requestItem.priority === 'high' ? '[HIGH] ' : ''; + + result.alerts.push( + `${priorityText}No stock available for ${part.sku}. ` + + `Requested: ${requestItem.quantity} units.` + ); + } + } + + if (result.pending.length > 0) { + const existingNotes = order.internalNotes || ''; + const now = new Date(); + const pendingSummary = result.pending + .map(p => `${p.partSku}: ${p.shortfall} units`) + .join(', '); + + order.internalNotes = existingNotes + + `\n[${now.toISOString()}] Parts pending: ${pendingSummary}`; + await this.orderRepository.save(order); + } + + return result; + } + + /** + * Pause work on an order + * + * Sets status to 'waiting_parts' or similar. + * Records pause reason and timestamp. + * + * @param tenantId - Tenant identifier + * @param orderId - Order to pause + * @param reason - Reason for pausing + * @returns Updated service order + * @throws Error if order not found or not in progress + */ + async pauseWork( + tenantId: string, + orderId: string, + reason: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + if (order.status !== ServiceOrderStatus.IN_PROGRESS) { + throw new Error( + `Cannot pause order in status '${order.status}'. ` + + `Order must be in status: in_progress` + ); + } + + order.status = ServiceOrderStatus.WAITING_PARTS; + + const now = new Date(); + const existingNotes = order.internalNotes || ''; + order.internalNotes = existingNotes + + `\n[${now.toISOString()}] Work paused: ${reason}`; + + return this.orderRepository.save(order); + } + + /** + * Resume work on an order + * + * Sets status back to 'in_progress'. + * Records resume timestamp. + * + * @param tenantId - Tenant identifier + * @param orderId - Order to resume + * @returns Updated service order + * @throws Error if order not found or not paused + */ + async resumeWork( + tenantId: string, + orderId: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + if (order.status !== ServiceOrderStatus.WAITING_PARTS) { + throw new Error( + `Cannot resume order in status '${order.status}'. ` + + `Order must be in status: waiting_parts` + ); + } + + order.status = ServiceOrderStatus.IN_PROGRESS; + + const now = new Date(); + const existingNotes = order.internalNotes || ''; + order.internalNotes = existingNotes + + `\n[${now.toISOString()}] Work resumed`; + + return this.orderRepository.save(order); + } + + /** + * Complete an entire order + * + * Validates all items are completed. + * Consumes all reserved parts. + * Sets order status to 'completed'. + * Sets completedAt timestamp. + * Calculates final totals. + * + * @param tenantId - Tenant identifier + * @param orderId - Order to complete + * @param odometerOut - Optional odometer reading at completion + * @returns Updated service order + * @throws Error if order not found or items not completed + */ + async completeOrder( + tenantId: string, + orderId: string, + odometerOut?: number + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + const validStatuses = [ + ServiceOrderStatus.IN_PROGRESS, + ServiceOrderStatus.WAITING_PARTS, + ]; + + if (!validStatuses.includes(order.status)) { + throw new Error( + `Cannot complete order in status '${order.status}'. ` + + `Order must be in status: ${validStatuses.join(' or ')}` + ); + } + + const items = await this.itemRepository.find({ where: { orderId } }); + + const incompleteItems = items.filter( + item => item.status !== OrderItemStatus.COMPLETED + ); + + if (incompleteItems.length > 0) { + const incompleteDescriptions = incompleteItems + .slice(0, 5) + .map(i => i.description) + .join(', '); + + throw new Error( + `Cannot complete order: ${incompleteItems.length} item(s) not completed. ` + + `Items: ${incompleteDescriptions}${incompleteItems.length > 5 ? '...' : ''}` + ); + } + + for (const item of items) { + if (item.itemType === OrderItemType.PART && item.partId) { + const isConsumed = item.notes?.includes('[Stock consumed]'); + if (!isConsumed) { + await this.consumePartStock(tenantId, item.partId, Number(item.quantity)); + + const existingNotes = item.notes || ''; + item.notes = existingNotes + '\n[Stock consumed]'; + await this.itemRepository.save(item); + } + } + } + + const now = new Date(); + order.status = ServiceOrderStatus.COMPLETED; + order.completedAt = now; + + if (odometerOut !== undefined) { + order.odometerOut = odometerOut; + } + + const existingNotes = order.internalNotes || ''; + order.internalNotes = existingNotes + + `\n[${now.toISOString()}] Order completed`; + + await this.recalculateTotals(orderId); + + return this.orderRepository.save(order); + } + + /** + * Get work status for an order + * + * Returns: + * - Items completed vs pending + * - Labor hours summary + * - Parts status (reserved, consumed) + * + * @param tenantId - Tenant identifier + * @param orderId - Order to get status for + * @returns Order work status summary + * @throws Error if order not found + */ + async getOrderWorkStatus( + tenantId: string, + orderId: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + const items = await this.itemRepository.find({ where: { orderId } }); + + const serviceItems = items.filter(i => i.itemType === OrderItemType.SERVICE); + const partItems = items.filter(i => i.itemType === OrderItemType.PART); + + const itemCounts = { + total: items.length, + pending: items.filter(i => i.status === OrderItemStatus.PENDING).length, + inProgress: items.filter(i => i.status === OrderItemStatus.IN_PROGRESS).length, + completed: items.filter(i => i.status === OrderItemStatus.COMPLETED).length, + }; + + let estimatedHours = 0; + let actualHours = 0; + + for (const item of serviceItems) { + estimatedHours += Number(item.estimatedHours) || 0; + actualHours += Number(item.actualHours) || 0; + } + + const efficiency = estimatedHours > 0 + ? (estimatedHours / Math.max(actualHours, 0.1)) * 100 + : 0; + + const partsCounts = { + total: partItems.length, + reserved: 0, + consumed: 0, + pending: 0, + }; + + for (const item of partItems) { + if (item.status === OrderItemStatus.COMPLETED) { + partsCounts.consumed++; + } else if (item.status === OrderItemStatus.IN_PROGRESS) { + partsCounts.reserved++; + } else { + partsCounts.pending++; + } + } + + const mechanicsMap = new Map(); + + for (const item of serviceItems) { + if (item.performedBy) { + const existing = mechanicsMap.get(item.performedBy) || { + itemsAssigned: 0, + hoursLogged: 0, + }; + + existing.itemsAssigned++; + existing.hoursLogged += Number(item.actualHours) || 0; + + mechanicsMap.set(item.performedBy, existing); + } + } + + const mechanics = Array.from(mechanicsMap.entries()).map(([id, data]) => ({ + id, + itemsAssigned: data.itemsAssigned, + hoursLogged: data.hoursLogged, + })); + + return { + orderId: order.id, + orderNumber: order.orderNumber, + status: order.status, + startedAt: order.startedAt || null, + completedAt: order.completedAt || null, + items: itemCounts, + labor: { + estimatedHours, + actualHours, + efficiency: Math.round(efficiency * 100) / 100, + }, + parts: partsCounts, + mechanics, + }; + } + + // ============================================ + // HELPER METHODS + // ============================================ + + /** + * Generate a unique session ID + */ + private generateSessionId(): string { + return `WS-${Date.now()}-${Math.random().toString(36).substring(2, 8)}`; + } + + /** + * Reserve stock for a part + */ + private async reservePartStock( + tenantId: string, + partId: string, + quantity: number + ): Promise { + const part = await this.partRepository.findOne({ + where: { id: partId, tenantId }, + }); + + if (!part) { + return false; + } + + const availableStock = part.currentStock - part.reservedStock; + + if (quantity > availableStock) { + throw new Error( + `Insufficient stock for part ${part.sku}. ` + + `Available: ${availableStock}, Requested: ${quantity}` + ); + } + + part.reservedStock = Number(part.reservedStock) + quantity; + await this.partRepository.save(part); + return true; + } + + /** + * Consume reserved stock for a part + */ + private async consumePartStock( + tenantId: string, + partId: string, + quantity: number + ): Promise { + const part = await this.partRepository.findOne({ + where: { id: partId, tenantId }, + }); + + if (!part) { + return false; + } + + part.reservedStock = Math.max(0, Number(part.reservedStock) - quantity); + part.currentStock = Math.max(0, Number(part.currentStock) - quantity); + await this.partRepository.save(part); + return true; + } + + /** + * Release reserved stock for a part + */ + private async releasePartStock( + tenantId: string, + partId: string, + quantity: number + ): Promise { + const part = await this.partRepository.findOne({ + where: { id: partId, tenantId }, + }); + + if (!part) { + return false; + } + + part.reservedStock = Math.max(0, Number(part.reservedStock) - quantity); + await this.partRepository.save(part); + return true; + } + + /** + * Recalculate order totals + */ + private async recalculateTotals(orderId: string): Promise { + const items = await this.itemRepository.find({ where: { orderId } }); + + let laborTotal = 0; + let partsTotal = 0; + + for (const item of items) { + if (item.itemType === OrderItemType.SERVICE) { + laborTotal += Number(item.subtotal); + } else { + partsTotal += Number(item.subtotal); + } + } + + const order = await this.orderRepository.findOne({ where: { id: orderId } }); + if (!order) { + return; + } + + order.laborTotal = laborTotal; + order.partsTotal = partsTotal; + + const subtotal = laborTotal + partsTotal; + const discountAmount = subtotal * (Number(order.discountPercent) / 100); + order.discountAmount = discountAmount; + + const taxableAmount = subtotal - discountAmount; + order.tax = taxableAmount * 0.16; + + order.grandTotal = taxableAmount + order.tax; + + await this.orderRepository.save(order); + } + + // ============================================ + // ADDITIONAL UTILITY METHODS + // ============================================ + + /** + * Get all in-progress orders for a mechanic + */ + async getMechanicWorkload( + tenantId: string, + mechanicId: string + ): Promise> { + const assignedItems = await this.itemRepository + .createQueryBuilder('item') + .innerJoin('item.order', 'order') + .where('order.tenant_id = :tenantId', { tenantId }) + .andWhere('item.performed_by = :mechanicId', { mechanicId }) + .andWhere('item.status != :completed', { completed: OrderItemStatus.COMPLETED }) + .getMany(); + + const orderIds = [...new Set(assignedItems.map(i => i.orderId))]; + + const result: Array<{ order: ServiceOrder; items: OrderItem[] }> = []; + + for (const orderId of orderIds) { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (order) { + const orderItems = assignedItems.filter(i => i.orderId === orderId); + result.push({ order, items: orderItems }); + } + } + + return result; + } + + /** + * Cancel a work item and release reserved parts + */ + async cancelWorkItem( + tenantId: string, + orderId: string, + itemId: string, + reason: string + ): Promise { + const order = await this.orderRepository.findOne({ + where: { id: orderId, tenantId }, + }); + + if (!order) { + throw new Error(`Service order not found: ${orderId}`); + } + + const item = await this.itemRepository.findOne({ + where: { id: itemId, orderId }, + }); + + if (!item) { + throw new Error(`Order item not found: ${itemId}`); + } + + if (item.status === OrderItemStatus.COMPLETED) { + throw new Error('Cannot cancel a completed item'); + } + + if (item.itemType === OrderItemType.PART && item.partId) { + await this.releasePartStock(tenantId, item.partId, Number(item.quantity)); + } + + await this.itemRepository.remove(item); + + const existingNotes = order.internalNotes || ''; + const now = new Date(); + order.internalNotes = existingNotes + + `\n[${now.toISOString()}] Item cancelled: ${item.description}. Reason: ${reason}`; + await this.orderRepository.save(order); + + await this.recalculateTotals(orderId); + + return true; + } + + /** + * Get parts pending for an order + */ + async getPendingParts( + tenantId: string, + orderId: string + ): Promise> { + const items = await this.itemRepository.find({ + where: { orderId, itemType: OrderItemType.PART }, + }); + + const result: Array<{ + item: OrderItem; + part: Part | null; + availableStock: number; + shortfall: number; + }> = []; + + for (const item of items) { + if (item.status === OrderItemStatus.COMPLETED) { + continue; + } + + let part: Part | null = null; + let availableStock = 0; + let shortfall = 0; + + if (item.partId) { + part = await this.partRepository.findOne({ + where: { id: item.partId, tenantId }, + }); + + if (part) { + availableStock = Math.max(0, part.currentStock - part.reservedStock); + shortfall = Math.max(0, Number(item.quantity) - availableStock); + } + } + + result.push({ item, part, availableStock, shortfall }); + } + + return result; + } +}