erp-retail/backend/src/modules/inventory/services/stock-adjustment.service.ts

631 lines
18 KiB
TypeScript

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<StockAdjustment> {
constructor(
repository: Repository<StockAdjustment>,
private readonly lineRepository: Repository<StockAdjustmentLine>,
private readonly dataSource: DataSource
) {
super(repository);
}
/**
* Generate adjustment number
*/
private async generateAdjustmentNumber(tenantId: string, type: AdjustmentType): Promise<string> {
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<ServiceResult<StockAdjustment>> {
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<ServiceResult<StockAdjustment>> {
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<ServiceResult<StockAdjustment>> {
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<ServiceResult<StockAdjustment>> {
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<ServiceResult<StockAdjustment>> {
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<ServiceResult<StockAdjustment>> {
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<StockAdjustment | null> {
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<AdjustmentType, { count: number; value: number }>;
byStatus: Record<AdjustmentStatus, number>;
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<string, { count: number; value: number }> = {};
const byStatus: Record<string, number> = {};
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<AdjustmentType, { count: number; value: number }>,
byStatus: byStatus as Record<AdjustmentStatus, number>,
totalIncreaseValue,
totalDecreaseValue,
netValue: totalIncreaseValue - totalDecreaseValue,
};
}
/**
* Add line to existing adjustment
*/
async addLine(
tenantId: string,
adjustmentId: string,
lineInput: AdjustmentLineInput,
userId: string
): Promise<ServiceResult<StockAdjustmentLine>> {
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<void> {
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,
}
);
}
}