erp-core-backend/src/modules/inventory/adjustments.service.ts
2025-12-12 14:39:29 -06:00

513 lines
15 KiB
TypeScript

import { query, queryOne, getClient } from '../../config/database.js';
import { NotFoundError, ValidationError, ConflictError } from '../../shared/errors/index.js';
export type AdjustmentStatus = 'draft' | 'confirmed' | 'done' | 'cancelled';
export interface AdjustmentLine {
id: string;
adjustment_id: string;
product_id: string;
product_name?: string;
product_code?: string;
location_id: string;
location_name?: string;
lot_id?: string;
lot_name?: string;
theoretical_qty: number;
counted_qty: number;
difference_qty: number;
uom_id: string;
uom_name?: string;
notes?: string;
created_at: Date;
}
export interface Adjustment {
id: string;
tenant_id: string;
company_id: string;
company_name?: string;
name: string;
location_id: string;
location_name?: string;
date: Date;
status: AdjustmentStatus;
notes?: string;
lines?: AdjustmentLine[];
created_at: Date;
}
export interface CreateAdjustmentLineDto {
product_id: string;
location_id: string;
lot_id?: string;
counted_qty: number;
uom_id: string;
notes?: string;
}
export interface CreateAdjustmentDto {
company_id: string;
location_id: string;
date?: string;
notes?: string;
lines: CreateAdjustmentLineDto[];
}
export interface UpdateAdjustmentDto {
location_id?: string;
date?: string;
notes?: string | null;
}
export interface UpdateAdjustmentLineDto {
counted_qty?: number;
notes?: string | null;
}
export interface AdjustmentFilters {
company_id?: string;
location_id?: string;
status?: AdjustmentStatus;
date_from?: string;
date_to?: string;
search?: string;
page?: number;
limit?: number;
}
class AdjustmentsService {
async findAll(tenantId: string, filters: AdjustmentFilters = {}): Promise<{ data: Adjustment[]; total: number }> {
const { company_id, location_id, status, date_from, date_to, search, page = 1, limit = 20 } = filters;
const offset = (page - 1) * limit;
let whereClause = 'WHERE a.tenant_id = $1';
const params: any[] = [tenantId];
let paramIndex = 2;
if (company_id) {
whereClause += ` AND a.company_id = $${paramIndex++}`;
params.push(company_id);
}
if (location_id) {
whereClause += ` AND a.location_id = $${paramIndex++}`;
params.push(location_id);
}
if (status) {
whereClause += ` AND a.status = $${paramIndex++}`;
params.push(status);
}
if (date_from) {
whereClause += ` AND a.date >= $${paramIndex++}`;
params.push(date_from);
}
if (date_to) {
whereClause += ` AND a.date <= $${paramIndex++}`;
params.push(date_to);
}
if (search) {
whereClause += ` AND (a.name ILIKE $${paramIndex} OR a.notes ILIKE $${paramIndex})`;
params.push(`%${search}%`);
paramIndex++;
}
const countResult = await queryOne<{ count: string }>(
`SELECT COUNT(*) as count FROM inventory.inventory_adjustments a ${whereClause}`,
params
);
params.push(limit, offset);
const data = await query<Adjustment>(
`SELECT a.*,
c.name as company_name,
l.name as location_name
FROM inventory.inventory_adjustments a
LEFT JOIN auth.companies c ON a.company_id = c.id
LEFT JOIN inventory.locations l ON a.location_id = l.id
${whereClause}
ORDER BY a.date DESC, a.created_at DESC
LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`,
params
);
return {
data,
total: parseInt(countResult?.count || '0', 10),
};
}
async findById(id: string, tenantId: string): Promise<Adjustment> {
const adjustment = await queryOne<Adjustment>(
`SELECT a.*,
c.name as company_name,
l.name as location_name
FROM inventory.inventory_adjustments a
LEFT JOIN auth.companies c ON a.company_id = c.id
LEFT JOIN inventory.locations l ON a.location_id = l.id
WHERE a.id = $1 AND a.tenant_id = $2`,
[id, tenantId]
);
if (!adjustment) {
throw new NotFoundError('Ajuste de inventario no encontrado');
}
// Get lines
const lines = await query<AdjustmentLine>(
`SELECT al.*,
p.name as product_name,
p.code as product_code,
l.name as location_name,
lot.name as lot_name,
u.name as uom_name
FROM inventory.inventory_adjustment_lines al
LEFT JOIN inventory.products p ON al.product_id = p.id
LEFT JOIN inventory.locations l ON al.location_id = l.id
LEFT JOIN inventory.lots lot ON al.lot_id = lot.id
LEFT JOIN core.uom u ON al.uom_id = u.id
WHERE al.adjustment_id = $1
ORDER BY al.created_at`,
[id]
);
adjustment.lines = lines;
return adjustment;
}
async create(dto: CreateAdjustmentDto, tenantId: string, userId: string): Promise<Adjustment> {
if (dto.lines.length === 0) {
throw new ValidationError('El ajuste debe tener al menos una línea');
}
const client = await getClient();
try {
await client.query('BEGIN');
// Generate adjustment name
const seqResult = await client.query(
`SELECT COALESCE(MAX(CAST(SUBSTRING(name FROM 5) AS INTEGER)), 0) + 1 as next_num
FROM inventory.inventory_adjustments WHERE tenant_id = $1 AND name LIKE 'ADJ-%'`,
[tenantId]
);
const nextNum = seqResult.rows[0]?.next_num || 1;
const adjustmentName = `ADJ-${String(nextNum).padStart(6, '0')}`;
const adjustmentDate = dto.date || new Date().toISOString().split('T')[0];
// Create adjustment
const adjustmentResult = await client.query(
`INSERT INTO inventory.inventory_adjustments (
tenant_id, company_id, name, location_id, date, notes, created_by
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[tenantId, dto.company_id, adjustmentName, dto.location_id, adjustmentDate, dto.notes, userId]
);
const adjustment = adjustmentResult.rows[0];
// Create lines with theoretical qty from stock_quants
for (const line of dto.lines) {
// Get theoretical quantity from stock_quants
const stockResult = await client.query(
`SELECT COALESCE(SUM(quantity), 0) as qty
FROM inventory.stock_quants
WHERE product_id = $1 AND location_id = $2
AND ($3::uuid IS NULL OR lot_id = $3)`,
[line.product_id, line.location_id, line.lot_id || null]
);
const theoreticalQty = parseFloat(stockResult.rows[0]?.qty || '0');
await client.query(
`INSERT INTO inventory.inventory_adjustment_lines (
adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty,
counted_qty
)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
[
adjustment.id, tenantId, line.product_id, line.location_id, line.lot_id,
theoreticalQty, line.counted_qty
]
);
}
await client.query('COMMIT');
return this.findById(adjustment.id, tenantId);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async update(id: string, dto: UpdateAdjustmentDto, tenantId: string, userId: string): Promise<Adjustment> {
const existing = await this.findById(id, tenantId);
if (existing.status !== 'draft') {
throw new ValidationError('Solo se pueden editar ajustes en estado borrador');
}
const updateFields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dto.location_id !== undefined) {
updateFields.push(`location_id = $${paramIndex++}`);
values.push(dto.location_id);
}
if (dto.date !== undefined) {
updateFields.push(`date = $${paramIndex++}`);
values.push(dto.date);
}
if (dto.notes !== undefined) {
updateFields.push(`notes = $${paramIndex++}`);
values.push(dto.notes);
}
if (updateFields.length === 0) {
return existing;
}
updateFields.push(`updated_by = $${paramIndex++}`);
values.push(userId);
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(id, tenantId);
await query(
`UPDATE inventory.inventory_adjustments SET ${updateFields.join(', ')}
WHERE id = $${paramIndex++} AND tenant_id = $${paramIndex}`,
values
);
return this.findById(id, tenantId);
}
async addLine(adjustmentId: string, dto: CreateAdjustmentLineDto, tenantId: string): Promise<AdjustmentLine> {
const adjustment = await this.findById(adjustmentId, tenantId);
if (adjustment.status !== 'draft') {
throw new ValidationError('Solo se pueden agregar líneas a ajustes en estado borrador');
}
// Get theoretical quantity
const stockResult = await queryOne<{ qty: string }>(
`SELECT COALESCE(SUM(quantity), 0) as qty
FROM inventory.stock_quants
WHERE product_id = $1 AND location_id = $2
AND ($3::uuid IS NULL OR lot_id = $3)`,
[dto.product_id, dto.location_id, dto.lot_id || null]
);
const theoreticalQty = parseFloat(stockResult?.qty || '0');
const line = await queryOne<AdjustmentLine>(
`INSERT INTO inventory.inventory_adjustment_lines (
adjustment_id, tenant_id, product_id, location_id, lot_id, theoretical_qty,
counted_qty
)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING *`,
[
adjustmentId, tenantId, dto.product_id, dto.location_id, dto.lot_id,
theoreticalQty, dto.counted_qty
]
);
return line!;
}
async updateLine(adjustmentId: string, lineId: string, dto: UpdateAdjustmentLineDto, tenantId: string): Promise<AdjustmentLine> {
const adjustment = await this.findById(adjustmentId, tenantId);
if (adjustment.status !== 'draft') {
throw new ValidationError('Solo se pueden editar líneas en ajustes en estado borrador');
}
const existingLine = adjustment.lines?.find(l => l.id === lineId);
if (!existingLine) {
throw new NotFoundError('Línea no encontrada');
}
const updateFields: string[] = [];
const values: any[] = [];
let paramIndex = 1;
if (dto.counted_qty !== undefined) {
updateFields.push(`counted_qty = $${paramIndex++}`);
values.push(dto.counted_qty);
}
if (dto.notes !== undefined) {
updateFields.push(`notes = $${paramIndex++}`);
values.push(dto.notes);
}
if (updateFields.length === 0) {
return existingLine;
}
values.push(lineId);
const line = await queryOne<AdjustmentLine>(
`UPDATE inventory.inventory_adjustment_lines SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
RETURNING *`,
values
);
return line!;
}
async removeLine(adjustmentId: string, lineId: string, tenantId: string): Promise<void> {
const adjustment = await this.findById(adjustmentId, tenantId);
if (adjustment.status !== 'draft') {
throw new ValidationError('Solo se pueden eliminar líneas en ajustes en estado borrador');
}
const existingLine = adjustment.lines?.find(l => l.id === lineId);
if (!existingLine) {
throw new NotFoundError('Línea no encontrada');
}
if (adjustment.lines && adjustment.lines.length <= 1) {
throw new ValidationError('El ajuste debe tener al menos una línea');
}
await query(`DELETE FROM inventory.inventory_adjustment_lines WHERE id = $1`, [lineId]);
}
async confirm(id: string, tenantId: string, userId: string): Promise<Adjustment> {
const adjustment = await this.findById(id, tenantId);
if (adjustment.status !== 'draft') {
throw new ValidationError('Solo se pueden confirmar ajustes en estado borrador');
}
if (!adjustment.lines || adjustment.lines.length === 0) {
throw new ValidationError('El ajuste debe tener al menos una línea');
}
await query(
`UPDATE inventory.inventory_adjustments SET
status = 'confirmed',
updated_by = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND tenant_id = $3`,
[userId, id, tenantId]
);
return this.findById(id, tenantId);
}
async validate(id: string, tenantId: string, userId: string): Promise<Adjustment> {
const adjustment = await this.findById(id, tenantId);
if (adjustment.status !== 'confirmed') {
throw new ValidationError('Solo se pueden validar ajustes confirmados');
}
const client = await getClient();
try {
await client.query('BEGIN');
// Update status to done
await client.query(
`UPDATE inventory.inventory_adjustments SET
status = 'done',
updated_by = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND tenant_id = $3`,
[userId, id, tenantId]
);
// Apply stock adjustments
for (const line of adjustment.lines!) {
const difference = line.counted_qty - line.theoretical_qty;
if (difference !== 0) {
// Check if quant exists
const existingQuant = await client.query(
`SELECT id, quantity FROM inventory.stock_quants
WHERE product_id = $1 AND location_id = $2
AND ($3::uuid IS NULL OR lot_id = $3)`,
[line.product_id, line.location_id, line.lot_id || null]
);
if (existingQuant.rows.length > 0) {
// Update existing quant
await client.query(
`UPDATE inventory.stock_quants SET
quantity = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2`,
[line.counted_qty, existingQuant.rows[0].id]
);
} else if (line.counted_qty > 0) {
// Create new quant if counted > 0
await client.query(
`INSERT INTO inventory.stock_quants (
tenant_id, product_id, location_id, lot_id, quantity
)
VALUES ($1, $2, $3, $4, $5)`,
[tenantId, line.product_id, line.location_id, line.lot_id, line.counted_qty]
);
}
}
}
await client.query('COMMIT');
return this.findById(id, tenantId);
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
}
async cancel(id: string, tenantId: string, userId: string): Promise<Adjustment> {
const adjustment = await this.findById(id, tenantId);
if (adjustment.status === 'done') {
throw new ValidationError('No se puede cancelar un ajuste validado');
}
if (adjustment.status === 'cancelled') {
throw new ValidationError('El ajuste ya está cancelado');
}
await query(
`UPDATE inventory.inventory_adjustments SET
status = 'cancelled',
updated_by = $1,
updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND tenant_id = $3`,
[userId, id, tenantId]
);
return this.findById(id, tenantId);
}
async delete(id: string, tenantId: string): Promise<void> {
const adjustment = await this.findById(id, tenantId);
if (adjustment.status !== 'draft') {
throw new ValidationError('Solo se pueden eliminar ajustes en estado borrador');
}
await query(`DELETE FROM inventory.inventory_adjustments WHERE id = $1 AND tenant_id = $2`, [id, tenantId]);
}
}
export const adjustmentsService = new AdjustmentsService();