erp-mecanicas-diesel-backen.../src/modules/parts-management/services/warehouse-entry.service.ts
Adrian Flores Cortes 74027be804 [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>
2026-02-03 01:15:27 -06:00

901 lines
24 KiB
TypeScript

/**
* 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;
}
}