import { Repository, DataSource, Between } from 'typeorm'; import { BaseService } from '../../../shared/services/base.service'; import { ServiceResult, QueryOptions } from '../../../shared/types'; import { StockAdjustment, AdjustmentStatus, AdjustmentType, } from '../entities/stock-adjustment.entity'; import { StockAdjustmentLine, AdjustmentDirection } from '../entities/stock-adjustment-line.entity'; export interface AdjustmentLineInput { productId: string; productCode: string; productName: string; productBarcode?: string; variantId?: string; variantName?: string; uomId?: string; uomName?: string; systemQuantity: number; countedQuantity: number; unitCost?: number; locationId?: string; locationCode?: string; lotNumber?: string; serialNumber?: string; expiryDate?: Date; lineReason?: string; notes?: string; } export interface CreateAdjustmentInput { branchId: string; warehouseId: string; locationId?: string; type: AdjustmentType; adjustmentDate: Date; isFullCount?: boolean; countCategoryId?: string; reason: string; notes?: string; lines: AdjustmentLineInput[]; } export interface AdjustmentQueryOptions extends QueryOptions { branchId?: string; warehouseId?: string; status?: AdjustmentStatus; type?: AdjustmentType; startDate?: Date; endDate?: Date; } export class StockAdjustmentService extends BaseService { constructor( repository: Repository, private readonly lineRepository: Repository, private readonly dataSource: DataSource ) { super(repository); } /** * Generate adjustment number */ private async generateAdjustmentNumber(tenantId: string, type: AdjustmentType): Promise { const today = new Date(); const datePrefix = today.toISOString().slice(0, 10).replace(/-/g, ''); const typePrefix = type === AdjustmentType.INVENTORY_COUNT ? 'CNT' : 'ADJ'; const count = await this.repository.count({ where: { tenantId, createdAt: Between( new Date(today.setHours(0, 0, 0, 0)), new Date(today.setHours(23, 59, 59, 999)) ), }, }); return `${typePrefix}-${datePrefix}-${String(count + 1).padStart(4, '0')}`; } /** * Create a new stock adjustment */ async createAdjustment( tenantId: string, input: CreateAdjustmentInput, userId: string ): Promise> { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const number = await this.generateAdjustmentNumber(tenantId, input.type); // Calculate totals let totalIncreaseQty = 0; let totalDecreaseQty = 0; let totalIncreaseValue = 0; let totalDecreaseValue = 0; for (const line of input.lines) { const adjustmentQty = line.countedQuantity - line.systemQuantity; const unitCost = line.unitCost ?? 0; if (adjustmentQty > 0) { totalIncreaseQty += adjustmentQty; totalIncreaseValue += adjustmentQty * unitCost; } else { totalDecreaseQty += Math.abs(adjustmentQty); totalDecreaseValue += Math.abs(adjustmentQty) * unitCost; } } const netAdjustmentValue = totalIncreaseValue - totalDecreaseValue; // Determine if approval is required const approvalThreshold = 10000; // Could be configurable const requiresApproval = Math.abs(netAdjustmentValue) >= approvalThreshold; const adjustment = queryRunner.manager.create(StockAdjustment, { tenantId, branchId: input.branchId, warehouseId: input.warehouseId, locationId: input.locationId, number, type: input.type, status: AdjustmentStatus.DRAFT, adjustmentDate: input.adjustmentDate, isFullCount: input.isFullCount ?? false, countCategoryId: input.countCategoryId, linesCount: input.lines.length, totalIncreaseQty, totalDecreaseQty, totalIncreaseValue, totalDecreaseValue, netAdjustmentValue, requiresApproval, approvalThreshold: requiresApproval ? approvalThreshold : null, reason: input.reason, notes: input.notes, createdBy: userId, }); const savedAdjustment = await queryRunner.manager.save(adjustment); // Create lines let lineNumber = 1; for (const lineInput of input.lines) { const adjustmentQuantity = lineInput.countedQuantity - lineInput.systemQuantity; const direction = adjustmentQuantity >= 0 ? AdjustmentDirection.INCREASE : AdjustmentDirection.DECREASE; const unitCost = lineInput.unitCost ?? 0; const line = queryRunner.manager.create(StockAdjustmentLine, { tenantId, adjustmentId: savedAdjustment.id, lineNumber: lineNumber++, productId: lineInput.productId, productCode: lineInput.productCode, productName: lineInput.productName, productBarcode: lineInput.productBarcode, variantId: lineInput.variantId, variantName: lineInput.variantName, uomId: lineInput.uomId, uomName: lineInput.uomName ?? 'PZA', systemQuantity: lineInput.systemQuantity, countedQuantity: lineInput.countedQuantity, adjustmentQuantity: Math.abs(adjustmentQuantity), direction, unitCost, adjustmentValue: Math.abs(adjustmentQuantity) * unitCost, locationId: lineInput.locationId, locationCode: lineInput.locationCode, lotNumber: lineInput.lotNumber, serialNumber: lineInput.serialNumber, expiryDate: lineInput.expiryDate, lineReason: lineInput.lineReason, notes: lineInput.notes, }); await queryRunner.manager.save(line); } await queryRunner.commitTransaction(); // Reload with lines const result = await this.repository.findOne({ where: { id: savedAdjustment.id, tenantId }, relations: ['lines'], }); return { success: true, data: result! }; } catch (error) { await queryRunner.rollbackTransaction(); return { success: false, error: { code: 'CREATE_ADJUSTMENT_ERROR', message: error instanceof Error ? error.message : 'Failed to create adjustment', }, }; } finally { await queryRunner.release(); } } /** * Submit adjustment for approval */ async submitForApproval( tenantId: string, id: string, userId: string ): Promise> { const adjustment = await this.repository.findOne({ where: { id, tenantId }, }); if (!adjustment) { return { success: false, error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, }; } if (adjustment.status !== AdjustmentStatus.DRAFT) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Adjustment is not in draft status' }, }; } adjustment.status = adjustment.requiresApproval ? AdjustmentStatus.PENDING_APPROVAL : AdjustmentStatus.APPROVED; if (!adjustment.requiresApproval) { adjustment.approvedBy = userId; adjustment.approvedAt = new Date(); } const saved = await this.repository.save(adjustment); return { success: true, data: saved }; } /** * Approve adjustment */ async approveAdjustment( tenantId: string, id: string, approverId: string ): Promise> { const adjustment = await this.repository.findOne({ where: { id, tenantId }, }); if (!adjustment) { return { success: false, error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, }; } if (adjustment.status !== AdjustmentStatus.PENDING_APPROVAL) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Adjustment is not pending approval' }, }; } adjustment.status = AdjustmentStatus.APPROVED; adjustment.approvedBy = approverId; adjustment.approvedAt = new Date(); const saved = await this.repository.save(adjustment); return { success: true, data: saved }; } /** * Reject adjustment */ async rejectAdjustment( tenantId: string, id: string, rejecterId: string, reason: string ): Promise> { const adjustment = await this.repository.findOne({ where: { id, tenantId }, }); if (!adjustment) { return { success: false, error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, }; } if (adjustment.status !== AdjustmentStatus.PENDING_APPROVAL) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Adjustment is not pending approval' }, }; } adjustment.status = AdjustmentStatus.REJECTED; adjustment.rejectedBy = rejecterId; adjustment.rejectedAt = new Date(); adjustment.rejectionReason = reason; const saved = await this.repository.save(adjustment); return { success: true, data: saved }; } /** * Post adjustment (apply to inventory) */ async postAdjustment( tenantId: string, id: string, userId: string ): Promise> { const queryRunner = this.dataSource.createQueryRunner(); await queryRunner.connect(); await queryRunner.startTransaction(); try { const adjustment = await queryRunner.manager.findOne(StockAdjustment, { where: { id, tenantId }, relations: ['lines'], }); if (!adjustment) { return { success: false, error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, }; } if (adjustment.status !== AdjustmentStatus.APPROVED) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Adjustment must be approved before posting' }, }; } // TODO: Update actual inventory stock levels // This would involve updating stock records in the inventory system // For each line: // - If direction is INCREASE, add adjustmentQuantity to stock // - If direction is DECREASE, subtract adjustmentQuantity from stock adjustment.status = AdjustmentStatus.POSTED; adjustment.postedBy = userId; adjustment.postedAt = new Date(); await queryRunner.manager.save(adjustment); await queryRunner.commitTransaction(); return { success: true, data: adjustment }; } catch (error) { await queryRunner.rollbackTransaction(); return { success: false, error: { code: 'POST_ADJUSTMENT_ERROR', message: error instanceof Error ? error.message : 'Failed to post adjustment', }, }; } finally { await queryRunner.release(); } } /** * Cancel adjustment */ async cancelAdjustment( tenantId: string, id: string, reason: string, userId: string ): Promise> { const adjustment = await this.repository.findOne({ where: { id, tenantId }, }); if (!adjustment) { return { success: false, error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, }; } if ([AdjustmentStatus.POSTED, AdjustmentStatus.CANCELLED].includes(adjustment.status)) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Cannot cancel a posted or already cancelled adjustment' }, }; } adjustment.status = AdjustmentStatus.CANCELLED; adjustment.notes = `${adjustment.notes ?? ''}\nCancelled: ${reason}`.trim(); const saved = await this.repository.save(adjustment); return { success: true, data: saved }; } /** * Find adjustments with filters */ async findAdjustments( tenantId: string, options: AdjustmentQueryOptions ): Promise<{ data: StockAdjustment[]; total: number }> { const qb = this.repository.createQueryBuilder('adjustment') .where('adjustment.tenantId = :tenantId', { tenantId }); if (options.branchId) { qb.andWhere('adjustment.branchId = :branchId', { branchId: options.branchId }); } if (options.warehouseId) { qb.andWhere('adjustment.warehouseId = :warehouseId', { warehouseId: options.warehouseId }); } if (options.status) { qb.andWhere('adjustment.status = :status', { status: options.status }); } if (options.type) { qb.andWhere('adjustment.type = :type', { type: options.type }); } if (options.startDate && options.endDate) { qb.andWhere('adjustment.adjustmentDate BETWEEN :startDate AND :endDate', { startDate: options.startDate, endDate: options.endDate, }); } const page = options.page ?? 1; const limit = options.limit ?? 20; qb.skip((page - 1) * limit).take(limit); qb.orderBy('adjustment.adjustmentDate', 'DESC'); const [data, total] = await qb.getManyAndCount(); return { data, total }; } /** * Get adjustment with lines */ async getAdjustmentWithLines( tenantId: string, id: string ): Promise { return this.repository.findOne({ where: { id, tenantId }, relations: ['lines'], }); } /** * Get adjustment summary by type and status */ async getAdjustmentSummary( tenantId: string, branchId?: string, startDate?: Date, endDate?: Date ): Promise<{ byType: Record; byStatus: Record; totalIncreaseValue: number; totalDecreaseValue: number; netValue: number; }> { const qb = this.repository.createQueryBuilder('adjustment') .where('adjustment.tenantId = :tenantId', { tenantId }); if (branchId) { qb.andWhere('adjustment.branchId = :branchId', { branchId }); } if (startDate && endDate) { qb.andWhere('adjustment.adjustmentDate BETWEEN :startDate AND :endDate', { startDate, endDate, }); } const adjustments = await qb.getMany(); const byType: Record = {}; const byStatus: Record = {}; let totalIncreaseValue = 0; let totalDecreaseValue = 0; for (const adj of adjustments) { // By type if (!byType[adj.type]) { byType[adj.type] = { count: 0, value: 0 }; } byType[adj.type].count++; byType[adj.type].value += Number(adj.netAdjustmentValue); // By status byStatus[adj.status] = (byStatus[adj.status] ?? 0) + 1; // Totals totalIncreaseValue += Number(adj.totalIncreaseValue); totalDecreaseValue += Number(adj.totalDecreaseValue); } return { byType: byType as Record, byStatus: byStatus as Record, totalIncreaseValue, totalDecreaseValue, netValue: totalIncreaseValue - totalDecreaseValue, }; } /** * Add line to existing adjustment */ async addLine( tenantId: string, adjustmentId: string, lineInput: AdjustmentLineInput, userId: string ): Promise> { const adjustment = await this.repository.findOne({ where: { id: adjustmentId, tenantId }, }); if (!adjustment) { return { success: false, error: { code: 'NOT_FOUND', message: 'Adjustment not found' }, }; } if (adjustment.status !== AdjustmentStatus.DRAFT) { return { success: false, error: { code: 'INVALID_STATUS', message: 'Can only add lines to draft adjustments' }, }; } const lastLine = await this.lineRepository.findOne({ where: { adjustmentId, tenantId }, order: { lineNumber: 'DESC' }, }); const adjustmentQuantity = lineInput.countedQuantity - lineInput.systemQuantity; const direction = adjustmentQuantity >= 0 ? AdjustmentDirection.INCREASE : AdjustmentDirection.DECREASE; const unitCost = lineInput.unitCost ?? 0; const line = this.lineRepository.create({ tenantId, adjustmentId, lineNumber: (lastLine?.lineNumber ?? 0) + 1, productId: lineInput.productId, productCode: lineInput.productCode, productName: lineInput.productName, productBarcode: lineInput.productBarcode, variantId: lineInput.variantId, variantName: lineInput.variantName, uomId: lineInput.uomId, uomName: lineInput.uomName ?? 'PZA', systemQuantity: lineInput.systemQuantity, countedQuantity: lineInput.countedQuantity, adjustmentQuantity: Math.abs(adjustmentQuantity), direction, unitCost, adjustmentValue: Math.abs(adjustmentQuantity) * unitCost, locationId: lineInput.locationId, locationCode: lineInput.locationCode, lotNumber: lineInput.lotNumber, serialNumber: lineInput.serialNumber, expiryDate: lineInput.expiryDate, lineReason: lineInput.lineReason, notes: lineInput.notes, }); const saved = await this.lineRepository.save(line); // Update adjustment totals await this.recalculateTotals(tenantId, adjustmentId); return { success: true, data: saved }; } /** * Recalculate adjustment totals from lines */ private async recalculateTotals(tenantId: string, adjustmentId: string): Promise { const lines = await this.lineRepository.find({ where: { adjustmentId, tenantId }, }); let totalIncreaseQty = 0; let totalDecreaseQty = 0; let totalIncreaseValue = 0; let totalDecreaseValue = 0; for (const line of lines) { if (line.direction === AdjustmentDirection.INCREASE) { totalIncreaseQty += Number(line.adjustmentQuantity); totalIncreaseValue += Number(line.adjustmentValue); } else { totalDecreaseQty += Number(line.adjustmentQuantity); totalDecreaseValue += Number(line.adjustmentValue); } } await this.repository.update( { id: adjustmentId, tenantId }, { linesCount: lines.length, totalIncreaseQty, totalDecreaseQty, totalIncreaseValue, totalDecreaseValue, netAdjustmentValue: totalIncreaseValue - totalDecreaseValue, } ); } }