Add audited Graphs stock balances
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s

This commit is contained in:
Cauê Faleiros
2026-08-24 14:34:25 -03:00
parent 71c80fd5d4
commit 7e18772a8a
7 changed files with 450 additions and 20 deletions

View File

@@ -97,6 +97,35 @@ const initDB = async () => {
ALTER COLUMN updated_at SET DEFAULT CURRENT_TIMESTAMP;
`).catch(() => {});
// Tiny/Olist remains the imported source balance. Graphs physical balances
// are stored separately so planning can use a verified count without
// overwriting the audit value received from Tiny.
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balances (
produto_id VARCHAR(100) PRIMARY KEY,
balance NUMERIC(14, 4) NOT NULL,
reason TEXT NOT NULL,
effective_at DATE,
updated_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_graphs_balance_history (
id SERIAL PRIMARY KEY,
produto_id VARCHAR(100) NOT NULL,
tiny_balance NUMERIC(14, 4),
graphs_balance NUMERIC(14, 4),
reason TEXT,
effective_at DATE,
action VARCHAR(20) NOT NULL DEFAULT 'set',
changed_by VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS stock_campaign_queue (
id SERIAL PRIMARY KEY,
@@ -675,6 +704,7 @@ const initDB = async () => {
});
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_campaign_queue_status ON stock_campaign_queue (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_stock_graphs_balance_history_product_created ON stock_graphs_balance_history (produto_id, created_at DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_status ON production_orders (status);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_issue_date ON production_orders (issue_date DESC);`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_production_orders_expected_date ON production_orders (expected_date DESC);`);

View File

@@ -1,6 +1,6 @@
const express = require('express');
const { authenticateAPIKey, verifyToken } = require('../auth');
const { listStock, upsertStockItems } = require('../services/stockService');
const { listGraphsBalanceHistory, listStock, removeGraphsBalance, saveGraphsBalance, upsertStockItems } = require('../services/stockService');
const router = express.Router();
@@ -13,6 +13,35 @@ router.get('/stock', verifyToken, async (req, res) => {
}
});
router.get('/stock/:productId/graphs-balance-history', verifyToken, async (req, res) => {
try {
res.json(await listGraphsBalanceHistory(req.params.productId));
} catch (error) {
console.error('Error fetching Graphs stock balance history:', error);
res.status(500).json({ error: 'Internal Server Error' });
}
});
router.put('/stock/:productId/graphs-balance', verifyToken, async (req, res) => {
try {
const balance = await saveGraphsBalance(req.params.productId, req.body, req.user?.email);
res.json(balance);
} catch (error) {
console.error('Error saving Graphs stock balance:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.delete('/stock/:productId/graphs-balance', verifyToken, async (req, res) => {
try {
const balance = await removeGraphsBalance(req.params.productId, req.user?.email);
res.json(balance);
} catch (error) {
console.error('Error removing Graphs stock balance:', error);
res.status(error.statusCode || 500).json({ error: error.message || 'Internal Server Error' });
}
});
router.post('/stock', authenticateAPIKey, async (req, res) => {
res.status(201).json({ message: 'Stock data received, processing in background' });

View File

@@ -5,10 +5,180 @@ const { enqueueStockCampaignItem } = require('./campaignService');
const POSITIVE_STOCK_DELTA_THRESHOLD = 1;
const listStock = async () => {
const result = await pool.query('SELECT * FROM stock');
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();
@@ -52,5 +222,8 @@ const upsertStockItems = async (payload) => {
module.exports = {
listStock,
listGraphsBalanceHistory,
saveGraphsBalance,
removeGraphsBalance,
upsertStockItems
};

View File

@@ -285,9 +285,10 @@ const listProjectDemandRows = async () => {
COALESCE(period_orders.produto_id, stock.produto_id) AS product_id,
COALESCE(NULLIF(period_orders.product_name, ''), NULLIF(stock.nome, ''), 'Produto sem nome') AS product_name,
COALESCE(period_orders.quantity_sold, 0)::numeric AS quantity_sold,
COALESCE(stock.saldo, 0)::numeric AS stock_quantity
COALESCE(graphs.balance, stock.saldo, 0)::numeric AS stock_quantity
FROM period_orders
FULL OUTER JOIN stock ON stock.produto_id = period_orders.produto_id
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE COALESCE(period_orders.produto_id, stock.produto_id, '') <> ''
ORDER BY quantity_sold DESC, product_name;
`, [DEFAULT_SUPPLY_LOOKBACK_DAYS]);
@@ -356,9 +357,15 @@ const listCompositionRows = async () => {
const listTinyStockRows = async () => {
const result = await pool.query(`
SELECT produto_id, nome, COALESCE(saldo, 0)::numeric AS saldo
SELECT
stock.produto_id,
stock.nome,
COALESCE(stock.saldo, 0)::numeric AS tiny_saldo,
graphs.balance::numeric AS graphs_balance,
COALESCE(graphs.balance, stock.saldo, 0)::numeric AS saldo
FROM stock
WHERE COALESCE(produto_id, '') <> '' OR COALESCE(nome, '') <> '';
LEFT JOIN stock_graphs_balances graphs ON graphs.produto_id = stock.produto_id
WHERE COALESCE(stock.produto_id, '') <> '' OR COALESCE(stock.nome, '') <> '';
`);
return result.rows;
};