[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:
parent
b455de93b2
commit
74027be804
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
121
src/modules/parts-management/entities/stock-alert.entity.ts
Normal file
121
src/modules/parts-management/entities/stock-alert.entity.ts
Normal 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;
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
829
src/modules/parts-management/services/stock-alert.service.ts
Normal file
829
src/modules/parts-management/services/stock-alert.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
900
src/modules/parts-management/services/warehouse-entry.service.ts
Normal file
900
src/modules/parts-management/services/warehouse-entry.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1101
src/modules/service-management/services/work-execution.service.ts
Normal file
1101
src/modules/service-management/services/work-execution.service.ts
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user