[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 <noreply@anthropic.com>
This commit is contained in:
Adrian Flores Cortes 2026-02-03 01:15:27 -06:00
parent b455de93b2
commit 74027be804
8 changed files with 3139 additions and 1 deletions

View File

@ -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';

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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';

View File

@ -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<Part>;
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<void> {
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;
}

View File

@ -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<StockAlert>;
private partRepository: Repository<Part>;
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<StockAlert | null> {
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<StockAlert | null> {
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<number> {
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<AlertStats> {
// 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<string, ReorderSuggestion[]>;
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<string, ReorderSuggestion[]>();
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<AlertNotificationBatch> {
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<StockAlert | null> {
return this.alertRepository.findOne({
where: { id, tenantId },
relations: ['part'],
});
}
/**
* Get alerts for a specific part
*/
async findByPartId(
tenantId: string,
partId: string,
includeResolved = false
): Promise<StockAlert[]> {
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<boolean> {
// 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<StockAlert> {
const alert = this.alertRepository.create({
tenantId,
partId,
alertType,
status: StockAlertStatus.ACTIVE,
currentStock,
threshold,
});
return this.alertRepository.save(alert);
}
}

View File

@ -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<MovementType, { count: number; quantity: number; value: number }>;
totalValue: number;
}
// ============================================================================
// Service
// ============================================================================
export class WarehouseEntryService {
private partRepository: Repository<Part>;
private supplierRepository: Repository<Supplier>;
private movementRepository: Repository<InventoryMovement>;
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<InventoryMovement> {
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<BulkEntryResult> {
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<ReceiveFromSupplierResult> {
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<InventoryMovement> {
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<TransferResult> {
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<MovementHistoryResult> {
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<InventoryMovement[]> {
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<number> {
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<DailyMovementsSummary> {
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, { count: number; quantity: number; value: number }> = {
[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<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 },
});
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<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 },
});
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<InventoryMovement> {
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;
}
}

File diff suppressed because it is too large Load Diff