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( `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 { const adjustment = await queryOne( `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( `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 { 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 { 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 { 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( `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 { 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( `UPDATE inventory.inventory_adjustment_lines SET ${updateFields.join(', ')} WHERE id = $${paramIndex} RETURNING *`, values ); return line!; } async removeLine(adjustmentId: string, lineId: string, tenantId: string): Promise { 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 { 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 { 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 { 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 { 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();