- 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>
901 lines
24 KiB
TypeScript
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;
|
|
}
|
|
}
|