Files
graphs/backend/services/stockService.js
Cauê Faleiros 7e18772a8a
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
Add audited Graphs stock balances
2026-08-24 14:34:25 -03:00

230 lines
7.8 KiB
JavaScript

const { pool } = require('../db');
const { normalizeStockPayload } = require('../mappers/stockMapper');
const { enqueueStockCampaignItem } = require('./campaignService');
const POSITIVE_STOCK_DELTA_THRESHOLD = 1;
const listStock = async () => {
const result = await pool.query(`
SELECT
stock.*,
graphs.balance::float8 AS graphs_balance,
graphs.reason AS graphs_balance_reason,
graphs.effective_at AS graphs_balance_effective_at,
graphs.updated_by AS graphs_balance_updated_by,
graphs.updated_at AS graphs_balance_updated_at,
COALESCE(graphs.balance, stock.saldo)::float8 AS planning_balance
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
ORDER BY stock.nome, stock.produto_id;
`);
return result.rows;
};
const getStockBalance = async (produtoId, client = pool) => {
const result = await client.query(`
SELECT
stock.*,
graphs.balance::float8 AS graphs_balance,
graphs.reason AS graphs_balance_reason,
graphs.effective_at AS graphs_balance_effective_at,
graphs.updated_by AS graphs_balance_updated_by,
graphs.updated_at AS graphs_balance_updated_at,
COALESCE(graphs.balance, stock.saldo)::float8 AS planning_balance
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE stock.produto_id = $1;
`, [produtoId]);
return result.rows[0] || null;
};
const listGraphsBalanceHistory = async (produtoId) => {
const result = await pool.query(`
SELECT
id,
produto_id,
tiny_balance::float8 AS tiny_balance,
graphs_balance::float8 AS graphs_balance,
reason,
effective_at,
action,
changed_by,
created_at
FROM stock_graphs_balance_history
WHERE produto_id = $1
ORDER BY created_at DESC, id DESC
LIMIT 20;
`, [produtoId]);
return result.rows;
};
const normalizeGraphsBalancePayload = (payload) => {
const balance = Number(payload?.balance);
const reason = String(payload?.reason || '').trim();
const effectiveAt = String(payload?.effectiveAt || '').trim() || null;
if (!Number.isFinite(balance)) {
const error = new Error('Informe um saldo físico válido para o Graphs.');
error.statusCode = 400;
throw error;
}
if (!reason) {
const error = new Error('Informe o motivo da conferência de saldo.');
error.statusCode = 400;
throw error;
}
if (effectiveAt && !/^\d{4}-\d{2}-\d{2}$/.test(effectiveAt)) {
const error = new Error('Informe uma data de referência válida.');
error.statusCode = 400;
throw error;
}
return { balance, reason, effectiveAt };
};
const saveGraphsBalance = async (produtoId, payload, updatedBy) => {
const normalizedProductId = String(produtoId || '').trim();
if (!normalizedProductId) {
const error = new Error('Material inválido para ajuste de saldo.');
error.statusCode = 400;
throw error;
}
const { balance, reason, effectiveAt } = normalizeGraphsBalancePayload(payload);
const client = await pool.connect();
try {
await client.query('BEGIN');
const stock = await client.query('SELECT saldo FROM stock WHERE produto_id = $1 FOR UPDATE', [normalizedProductId]);
if (!stock.rows[0]) {
const error = new Error('Material não encontrado no estoque importado.');
error.statusCode = 404;
throw error;
}
await client.query(`
INSERT INTO stock_graphs_balances (produto_id, balance, reason, effective_at, updated_by)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (produto_id) DO UPDATE SET
balance = EXCLUDED.balance,
reason = EXCLUDED.reason,
effective_at = EXCLUDED.effective_at,
updated_by = EXCLUDED.updated_by,
updated_at = CURRENT_TIMESTAMP;
`, [normalizedProductId, balance, reason, effectiveAt, updatedBy || null]);
await client.query(`
INSERT INTO stock_graphs_balance_history (
produto_id, tiny_balance, graphs_balance, reason, effective_at, action, changed_by
) VALUES ($1, $2, $3, $4, $5, 'set', $6);
`, [normalizedProductId, stock.rows[0].saldo, balance, reason, effectiveAt, updatedBy || null]);
const saved = await getStockBalance(normalizedProductId, client);
await client.query('COMMIT');
return saved;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const removeGraphsBalance = async (produtoId, updatedBy) => {
const normalizedProductId = String(produtoId || '').trim();
const client = await pool.connect();
try {
await client.query('BEGIN');
const current = await client.query(`
SELECT stock.saldo, graphs.balance, graphs.reason, graphs.effective_at
FROM stock
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE stock.produto_id = $1
FOR UPDATE OF stock;
`, [normalizedProductId]);
if (!current.rows[0]) {
const error = new Error('Material não encontrado no estoque importado.');
error.statusCode = 404;
throw error;
}
if (current.rows[0].balance === null) {
const error = new Error('Não há saldo Graphs para remover.');
error.statusCode = 404;
throw error;
}
await client.query('DELETE FROM stock_graphs_balances WHERE produto_id = $1', [normalizedProductId]);
await client.query(`
INSERT INTO stock_graphs_balance_history (
produto_id, tiny_balance, graphs_balance, reason, effective_at, action, changed_by
) VALUES ($1, $2, $3, $4, $5, 'removed', $6);
`, [
normalizedProductId,
current.rows[0].saldo,
current.rows[0].balance,
current.rows[0].reason,
current.rows[0].effective_at,
updatedBy || null
]);
const saved = await getStockBalance(normalizedProductId, client);
await client.query('COMMIT');
return saved;
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
const upsertStockItems = async (payload) => {
const client = await pool.connect();
try {
await client.query('BEGIN');
const insertQuery = `
INSERT INTO stock (produto_id, nome, saldo, delta_estoque)
VALUES ($1, $2, $3, $4)
ON CONFLICT (produto_id) DO UPDATE SET
nome = EXCLUDED.nome,
saldo = EXCLUDED.saldo,
delta_estoque = EXCLUDED.delta_estoque,
updated_at = CURRENT_TIMESTAMP
`;
for (const rawItem of payload) {
const item = normalizeStockPayload(rawItem);
if (!item.produtoId) continue;
await client.query(insertQuery, [
item.produtoId,
item.nome,
item.saldo,
item.deltaEstoque
]);
if (item.deltaEstoque >= POSITIVE_STOCK_DELTA_THRESHOLD) {
await enqueueStockCampaignItem(client, item);
}
}
await client.query('COMMIT');
} catch (error) {
await client.query('ROLLBACK');
throw error;
} finally {
client.release();
}
};
module.exports = {
listStock,
listGraphsBalanceHistory,
saveGraphsBalance,
removeGraphsBalance,
upsertStockItems
};