From 7e18772a8a1b15127f470d6cba1c64df0875ce5a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 24 Aug 2026 14:34:25 -0300 Subject: [PATCH] Add audited Graphs stock balances --- backend/db.js | 30 +++++ backend/routes/stockRoutes.js | 31 +++++- backend/services/stockService.js | 175 +++++++++++++++++++++++++++++- backend/services/supplyService.js | 13 ++- src/dataService.ts | 26 ++++- src/pages/PlanningIssues.tsx | 171 ++++++++++++++++++++++++++--- src/types.ts | 24 ++++ 7 files changed, 450 insertions(+), 20 deletions(-) diff --git a/backend/db.js b/backend/db.js index 8f132e1..a3c0de1 100644 --- a/backend/db.js +++ b/backend/db.js @@ -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);`); diff --git a/backend/routes/stockRoutes.js b/backend/routes/stockRoutes.js index 1950951..5f29db6 100644 --- a/backend/routes/stockRoutes.js +++ b/backend/routes/stockRoutes.js @@ -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' }); diff --git a/backend/services/stockService.js b/backend/services/stockService.js index 343ee8c..9673f8b 100644 --- a/backend/services/stockService.js +++ b/backend/services/stockService.js @@ -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 }; diff --git a/backend/services/supplyService.js b/backend/services/supplyService.js index 85caca5..bf43da2 100644 --- a/backend/services/supplyService.js +++ b/backend/services/supplyService.js @@ -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; }; diff --git a/src/dataService.ts b/src/dataService.ts index 361e7ce..e078df9 100644 --- a/src/dataService.ts +++ b/src/dataService.ts @@ -1,4 +1,4 @@ -import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; +import type { AuthUser, CampaignPreview, CampaignProcessSummary, CampaignQueueSummary, CatalogCategory, CatalogCategoryPayload, CatalogProduct, CatalogProductPayload, CatalogSummary, ClientAnalyticsItem, ClientDetailsAnalytics, ClientFilterOptions, ClientMetadataFilters, ClientPurchasePatternAnalytics, ConsumptionReference, ConsumptionReferencePayload, CreateProductionOrdersResult, CreateUserResult, CuttingSettings, DashboardAnalytics, DateRange, GraphsStockBalanceHistoryItem, GraphsStockBalancePayload, ManagedUser, OrderData, OlistRunDetails, OlistStatus, ProductAnalyticsItem, ProductComposition, ProductCompositionImportSummary, ProductCompositionPayload, ProductDetailsAnalytics, ProductionOrderImportData, ProductionOrderItem, ProductionOrderPayload, ProductionOrderStatus, ProductionOrderSummary, RfmAnalytics, SellerAnalyticsItem, SellerDetailsAnalytics, StockData, SupplyFabricPlan, SupplyFabricPlanPayload, SupplyInventoryAdjustmentPayload, SupplyLot, SupplyMaterialDetailsPayload, SupplyProductionExitPayload, SupplyPurchaseNeed, SupplyReceipt, SupplyReceiptPayload, SupplySummary } from './types'; import { formatDateParam } from './dateRanges'; const API_URL = import.meta.env.VITE_API_URL || '/api'; @@ -139,6 +139,30 @@ export const fetchStock = async (): Promise => { } }; +export const fetchGraphsStockBalanceHistory = async (productId: string): Promise => { + const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance-history`); + if (!response.ok) throw new Error('Não foi possível consultar o histórico de saldo Graphs.'); + return response.json() as Promise; +}; + +export const saveGraphsStockBalance = async (productId: string, payload: GraphsStockBalancePayload): Promise => { + const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await response.json().catch(() => null) as StockData | { error?: string } | null; + if (!response.ok) throw new Error((data as { error?: string } | null)?.error || 'Não foi possível salvar o saldo Graphs.'); + return data as StockData; +}; + +export const deleteGraphsStockBalance = async (productId: string): Promise => { + const response = await authFetch(`/stock/${encodeURIComponent(productId)}/graphs-balance`, { method: 'DELETE' }); + const data = await response.json().catch(() => null) as StockData | { error?: string } | null; + if (!response.ok) throw new Error((data as { error?: string } | null)?.error || 'Não foi possível remover o saldo Graphs.'); + return data as StockData; +}; + export const fetchData = async (): Promise => { try { const token = localStorage.getItem('auth_token'); diff --git a/src/pages/PlanningIssues.tsx b/src/pages/PlanningIssues.tsx index 559da7c..28ddb6a 100644 --- a/src/pages/PlanningIssues.tsx +++ b/src/pages/PlanningIssues.tsx @@ -1,16 +1,16 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link, useOutletContext } from 'react-router-dom'; -import { AlertTriangle, CheckCircle2, Download, Eye, PackageSearch, Pencil, Search } from 'lucide-react'; +import { AlertTriangle, CheckCircle2, Download, Eye, History, PackageSearch, Pencil, RotateCcw, Save, Search, X } from 'lucide-react'; import DateRangePicker from '../components/DateRangePicker'; import PaginationControls from '../components/PaginationControls'; import ProductTypeBadge from '../components/ProductTypeBadge'; import RefreshStatus from '../components/RefreshStatus'; import SkuPlanningModal from '../components/SkuPlanningModal'; import { classifyCutFamily, CUT_FAMILY_RULES, type CutFamilyKey } from '../analytics/cutting'; -import { exportToCSV, fetchCuttingSettings, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings } from '../dataService'; +import { deleteGraphsStockBalance, exportToCSV, fetchCuttingSettings, fetchGraphsStockBalanceHistory, fetchProductAnalytics, fetchStock, fetchSupplySummary, saveCuttingSettings, saveGraphsStockBalance } from '../dataService'; import { getProductTypeConfig, resolveProductType, type ProductTypeKey } from '../productClassification'; import { parseProductName } from '../productParsing'; -import type { CutProductOverride, CuttingSettings, DateRange, ProductAnalyticsItem, StockData, SupplySummary } from '../types'; +import type { CutProductOverride, CuttingSettings, DateRange, GraphsStockBalanceHistoryItem, ProductAnalyticsItem, StockData, SupplySummary } from '../types'; type IssueKey = 'review_type' | 'missing_cut_family' | 'missing_color' | 'missing_size' | 'missing_yield'; type IssueFilter = 'all' | IssueKey; @@ -140,6 +140,121 @@ const PlanningIssuesSkeleton = () => ( ); +const formatDate = (value: string | null | undefined) => { + if (!value) return '—'; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? '—' : new Intl.DateTimeFormat('pt-BR', { dateStyle: 'short' }).format(date); +}; + +const StockGraphsBalanceModal = ({ + stock, + onClose, + onSaved +}: { + stock: StockData; + onClose: () => void; + onSaved: (stock: StockData) => void; +}) => { + const [balance, setBalance] = useState(stock.graphs_balance === null || stock.graphs_balance === undefined ? '' : String(stock.graphs_balance)); + const [reason, setReason] = useState(stock.graphs_balance_reason || 'Conferência física de estoque'); + const [effectiveAt, setEffectiveAt] = useState(stock.graphs_balance_effective_at?.slice(0, 10) || new Date().toISOString().slice(0, 10)); + const [history, setHistory] = useState([]); + const [isLoadingHistory, setIsLoadingHistory] = useState(true); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(''); + + const loadHistory = useCallback(async () => { + setIsLoadingHistory(true); + try { + setHistory(await fetchGraphsStockBalanceHistory(stock.produto_id)); + } catch (historyError) { + setError(historyError instanceof Error ? historyError.message : 'Não foi possível carregar o histórico.'); + } finally { + setIsLoadingHistory(false); + } + }, [stock.produto_id]); + + useEffect(() => { + const timer = window.setTimeout(() => { void loadHistory(); }, 0); + return () => window.clearTimeout(timer); + }, [loadHistory]); + + const save = async () => { + const normalizedBalance = Number(balance.replace(',', '.')); + if (!Number.isFinite(normalizedBalance)) { + setError('Informe um saldo físico válido.'); + return; + } + setIsSaving(true); + setError(''); + try { + const saved = await saveGraphsStockBalance(stock.produto_id, { balance: normalizedBalance, reason, effectiveAt }); + onSaved(saved); + setBalance(String(saved.graphs_balance ?? normalizedBalance)); + await loadHistory(); + } catch (saveError) { + setError(saveError instanceof Error ? saveError.message : 'Não foi possível salvar o saldo Graphs.'); + } finally { + setIsSaving(false); + } + }; + + const remove = async () => { + if (!window.confirm('Remover o saldo Graphs e voltar a usar o saldo importado do Tiny no planejamento?')) return; + setIsSaving(true); + setError(''); + try { + const saved = await deleteGraphsStockBalance(stock.produto_id); + onSaved(saved); + setBalance(''); + await loadHistory(); + } catch (removeError) { + setError(removeError instanceof Error ? removeError.message : 'Não foi possível remover o saldo Graphs.'); + } finally { + setIsSaving(false); + } + }; + + const planningBalance = Math.max(0, Number(stock.planning_balance ?? stock.saldo)); + + return
+
event.stopPropagation()}> +
+
+

Material #{stock.produto_id}

+

{stock.nome}

+

O saldo Tiny permanece preservado para auditoria. O saldo Graphs passa a ser a base do planejamento.

+
+ +
+ +
+

Saldo Tiny

{formatNumber(Number(stock.saldo), 2)} un.

+

Saldo Graphs

{stock.graphs_balance === null || stock.graphs_balance === undefined ? 'Não informado' : `${formatNumber(Number(stock.graphs_balance), 2)} un.`}

+

Disponível no plano

{formatNumber(planningBalance, 2)} un.

+
+ +
+
+ + +