631 lines
18 KiB
TypeScript
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,
|
|
}
|
|
);
|
|
}
|
|
}
|