Add audited Graphs stock balances
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
All checks were successful
Build and Deploy / build-and-deploy (push) Successful in 1m33s
This commit is contained in:
@@ -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);`);
|
||||
|
||||
@@ -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' });
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user