513 lines
15 KiB
TypeScript
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();
|