ajustes de funcionalidade

This commit is contained in:
joao.herculano 2026-05-28 09:24:43 -03:00
parent ab1445574f
commit f75926bc3c
4 changed files with 1539 additions and 8 deletions

View File

@ -0,0 +1,967 @@
{% load static %}
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Controle de Saldo</title>
<style>
:root {
--header-h: 64px;
--accent: #03506B;
--sidebar-w: 260px;
--accent-light: #046b8f;
--bg-color: #f6f8fb;
--text-primary: #111;
--text-secondary: #6b7280;
--border-color: #e5e7eb;
--white: #fff;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
html, body {
height: 100%;
font-family: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
background: var(--bg-color);
color: var(--text-primary);
}
.home-container {
min-height: 100vh;
display: flex;
flex-direction: column;
}
.home-header {
height: var(--header-h);
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 20px;
background: var(--accent);
color: var(--white);
position: relative;
z-index: 1200;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.08);
}
.logo-container {
display: flex;
align-items: center;
gap: 12px;
}
.btn-sidebar {
background: none;
border: none;
color: var(--white);
cursor: pointer;
padding: 8px;
display: flex;
align-items: center;
}
.btn-sidebar svg {
width: 24px;
height: 24px;
transition: opacity 0.2s;
}
.btn-sidebar:hover svg {
opacity: 0.85;
}
.logo-img {
height: 40px;
max-width: 100%;
object-fit: contain;
}
.header-actions {
display: flex;
gap: 8px;
align-items: center;
}
.btn-header {
background: rgba(255, 255, 255, 0.15);
border: 1px solid rgba(255, 255, 255, 0.25);
color: var(--white);
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
gap: 6px;
text-decoration: none;
transition: all 0.2s ease;
}
.btn-header:hover {
background: rgba(255, 255, 255, 0.25);
}
.sidebar {
position: fixed;
top: var(--header-h);
left: 0;
width: var(--sidebar-w);
height: calc(100vh - var(--header-h));
background: var(--white);
border-right: 1px solid var(--border-color);
padding: 14px 10px;
box-shadow: 4px 0 30px rgba(0, 0, 0, 0.18);
transform: translateX(-100%);
transition: transform 0.28s ease;
z-index: 5000;
overflow-y: auto;
}
.sidebar.open {
transform: translateX(0);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: 6px;
}
.sidebar-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
color: #333;
text-decoration: none;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s ease;
}
.sidebar-item:hover {
background: #f1f5f9;
}
.sidebar-item.active {
background: var(--accent);
color: var(--white);
}
.sidebar-item svg {
width: 16px;
height: 16px;
}
.backdrop {
position: fixed;
inset: var(--header-h) 0 0 0;
background: rgba(0, 0, 0, 0.25);
opacity: 0;
visibility: hidden;
transition: opacity 0.28s, visibility 0.28s;
z-index: 4800;
}
.backdrop.show {
opacity: 1;
visibility: visible;
}
.main-wrap {
padding: 18px;
flex: 1;
box-sizing: border-box;
}
.chart-panel {
background: var(--white);
border-radius: 10px;
padding: 16px;
box-shadow: 0 2px 10px rgba(15, 23, 42, 0.06);
border: 1px solid var(--border-color);
max-width: 1200px;
margin: 0 auto;
}
.panel-title {
margin: 0 0 14px 0;
font-size: 20px;
font-weight: 700;
}
.transfer-info {
margin-bottom: 12px;
padding: 10px 12px;
border-radius: 9px;
background: #eff6ff;
color: #1e3a8a;
border: 1px solid #bfdbfe;
font-size: 13px;
font-weight: 600;
}
.top-actions {
display: flex;
justify-content: flex-end;
margin-bottom: 10px;
}
.btn-clear {
background: #f8fafc;
border: 1px solid var(--border-color);
color: #334155;
padding: 7px 12px;
border-radius: 8px;
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.btn-clear:hover {
background: #eef2f7;
}
.coverage-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.coverage-card {
border: 1px solid var(--border-color);
border-radius: 10px;
background: #fbfdff;
padding: 10px 12px;
}
.coverage-title {
margin: 0 0 4px 0;
font-size: 12px;
color: var(--text-secondary);
font-weight: 700;
}
.coverage-value {
margin: 0;
font-size: 18px;
font-weight: 800;
}
.coverage-green { color: #15803d; }
.coverage-yellow { color: #a16207; }
.coverage-red { color: #b91c1c; }
.saldo-negative { color: #b91c1c; font-weight: 700; }
.selectors,
.balances {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.field label,
.slider-wrap label {
display: block;
margin-bottom: 6px;
font-size: 12px;
color: var(--text-secondary);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.field input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
background: var(--white);
min-height: 36px;
}
.balance-card {
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 10px 12px;
background: #fbfdff;
}
.balance-title { margin: 0 0 4px 0; font-size: 12px; color: var(--text-secondary); font-weight: 700; }
.balance-value { margin: 0; font-size: 22px; font-weight: 800; }
.balance-value-small { margin: 0; font-size: 19px; font-weight: 800; }
.saldo-summary {
border: 1px solid var(--border-color);
border-radius: 10px;
background: #fbfdff;
padding: 10px 12px;
margin-bottom: 12px;
}
.saldo-summary-title {
margin: 0 0 8px 0;
font-size: 12px;
color: var(--text-secondary);
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.saldo-summary-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 10px;
}
.slider-wrap {
border-top: 1px solid var(--border-color);
padding-top: 12px;
}
.slider-compact {
display: grid;
grid-template-columns: 110px 1fr 110px;
align-items: center;
gap: 10px;
background: #f8fafc;
border: 1px solid var(--border-color);
border-radius: 10px;
padding: 8px 10px;
}
.slider-center {
width: 50%;
margin: 0 auto;
}
.slider-pill {
font-size: 12px;
color: #1e3a8a;
background: #dbeafe;
border: 1px solid #bfdbfe;
border-radius: 999px;
padding: 4px 8px;
text-align: center;
font-weight: 700;
}
#transferSlider {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
background: linear-gradient(90deg, #93c5fd, #3b82f6);
border-radius: 999px;
outline: none;
}
#transferSlider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
border: 2px solid #fff;
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
cursor: pointer;
}
#transferSlider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #1d4ed8;
border: 2px solid #fff;
box-shadow: 0 1px 5px rgba(0,0,0,0.2);
cursor: pointer;
}
.slider-meta { margin-top: 8px; font-size: 12px; color: var(--text-secondary); }
.target-wrap {
margin-top: 10px;
}
.target-wrap input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
background: var(--white);
min-height: 36px;
}
.transfer-wrap {
margin-top: 10px;
}
.transfer-wrap input {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border-color);
border-radius: 8px;
font-size: 14px;
background: var(--white);
min-height: 36px;
}
.loading, .error {
padding: 10px 12px;
border-radius: 8px;
margin-top: 10px;
font-size: 13px;
}
.loading { background: #eff6ff; color: #1e40af; }
.error { background: #fef2f2; color: #b91c1c; display: none; }
.saldo-table-wrap {
margin-top: 18px;
border-top: 1px solid var(--border-color);
padding-top: 14px;
}
.saldo-table-title {
margin: 0 0 10px 0;
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.saldo-table-container {
overflow-x: auto;
border: 1px solid var(--border-color);
border-radius: 10px;
background: #fff;
}
.saldo-table {
width: 100%;
border-collapse: collapse;
min-width: 620px;
}
.saldo-table th,
.saldo-table td {
padding: 9px 10px;
border-bottom: 1px solid #eef2f7;
text-align: left;
font-size: 13px;
white-space: nowrap;
}
.saldo-table th {
background: #f8fafc;
color: #334155;
font-weight: 700;
position: sticky;
top: 0;
z-index: 1;
}
.saldo-table tbody tr:hover {
background: #f8fafc;
}
footer {
background: var(--white);
border-top: 1px solid #eaeef5;
padding: 12px;
text-align: center;
color: #64748b;
font-size: 13px;
}
@media (max-width: 900px) {
.selectors, .balances, .coverage-row, .saldo-summary-grid { grid-template-columns: 1fr; }
.slider-compact { grid-template-columns: 1fr; gap: 8px; }
.slider-center { width: 100%; }
.balance-value { font-size: 19px; }
.header-actions { flex-wrap: wrap; }
}
</style>
</head>
<body>
<div class="home-container">
<header class="home-header">
<div class="logo-container">
<button id="toggleSidebar" class="btn-sidebar" title="Abrir menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M12 2L2 7L12 12L22 7L12 2Z" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 17L12 22L22 17" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M2 12L12 17L22 12" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<img src="https://api.grupoginseng.com.br/content-thumbs/logo.ginseng.branca.png" alt="Logo Ginseng" class="logo-img">
</div>
<div class="header-actions">
<a class="btn-header" href="/" title="Voltar para o Home">Voltar ao Home</a>
</div>
</header>
<aside id="sidebar" class="sidebar" role="navigation">
<nav class="sidebar-nav">
<a class="sidebar-item" href="/" title="Dashboard">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="7" height="7"/>
<rect x="14" y="3" width="7" height="7"/>
<rect x="14" y="14" width="7" height="7"/>
<rect x="3" y="14" width="7" height="7"/>
</svg>
Dashboard
</a>
<a class="sidebar-item" href="#/vendas" title="Vendas">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="2" x2="12" y2="22"/>
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
Vendas
</a>
<a class="sidebar-item" href="#/sugestoes" title="Sugestões">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
</svg>
Sugestões
</a>
<a class="sidebar-item" href="#/relatorios" title="Relatórios">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="12" y1="5" x2="12" y2="19"/>
<line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Relatórios
</a>
<a class="sidebar-item active" href="/home/controle-saldo/" title="Controle de Saldo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 7h16M4 12h10M4 17h16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 10l4 2-4 2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Controle de Saldo
</a>
</nav>
</aside>
<div id="backdrop" class="backdrop"></div>
<main class="main-wrap">
<section class="chart-panel" aria-label="Transferência de saldo">
<h2 class="panel-title">Transferência de saldo</h2>
<div class="transfer-info" id="transferInfo">Selecione duas combinações de PDV + Marca para iniciar a simulação.</div>
<div class="top-actions">
<button id="btnLimparDados" class="btn-clear" type="button">Limpar Dados</button>
</div>
<div class="selectors">
<div class="field">
<label for="pdvOrigem">PDV + Marca Origem (esquerda)</label>
<input id="pdvOrigem" list="pdvOrigemList" autocomplete="off" placeholder="Digite ou selecione PDV + Marca" />
<datalist id="pdvOrigemList"></datalist>
</div>
<div class="field">
<label for="pdvDestino">PDV + Marca Destino (direita)</label>
<input id="pdvDestino" list="pdvDestinoList" autocomplete="off" placeholder="Digite ou selecione PDV + Marca" />
<datalist id="pdvDestinoList"></datalist>
</div>
</div>
<div class="balances">
<div class="balance-card">
<p class="balance-title">Orçamento disponível Mês - Origem</p>
<p class="balance-value" id="saldoOrigem">R$ 0,00</p>
</div>
<div class="balance-card">
<p class="balance-title">Orçamento disponível Mês - Destino</p>
<p class="balance-value" id="saldoDestino">R$ 0,00</p>
</div>
</div>
<div class="saldo-summary">
<p class="saldo-summary-title">Composição do saldo (PDV origem)</p>
<div class="saldo-summary-grid">
<div class="balance-card">
<p class="balance-title">Total Orçamento do mês</p>
<p class="balance-value-small" id="orcamentoMesOrigem">R$ 0,00</p>
</div>
<div class="balance-card">
<p class="balance-title">Pendente - Ignorado</p>
<p class="balance-value-small" id="pendenteMenosIgnoradoOrigem">R$ 0,00</p>
</div>
<div class="balance-card">
<p class="balance-title">Saldo</p>
<p class="balance-value-small" id="saldoCompostoOrigem">R$ 0,00</p>
</div>
</div>
</div>
<div class="coverage-row">
<div class="coverage-card">
<p class="coverage-title">Cobertura PDV origem</p>
<p class="coverage-value" id="coberturaOrigem">-</p>
</div>
<div class="coverage-card">
<p class="coverage-title">Cobertura PDV destino</p>
<p class="coverage-value" id="coberturaDestino">-</p>
</div>
</div>
<div class="slider-wrap">
<label for="transferSlider">Valor para transferir da esquerda para a direita</label>
<div class="slider-center">
<div class="slider-compact">
<div class="slider-pill">Origem</div>
<input type="range" id="transferSlider" min="0" max="0" step="0.01" value="0" />
<div class="slider-pill">Destino</div>
</div>
<div class="target-wrap">
<label for="saldoDestinoDesejado">Saldo desejado no PDV destino</label>
<input id="saldoDestinoDesejado" type="number" min="0" step="0.01" placeholder="Digite o saldo final desejado no destino" />
</div>
<div class="transfer-wrap">
<label for="quantidadeTransferir">Quantidade a transferir</label>
<input id="quantidadeTransferir" type="number" min="0" step="0.01" placeholder="Digite o valor a retirar do PDV origem" />
</div>
</div>
<div class="slider-meta" id="sliderMeta">Transferência: R$ 0,00</div>
</div>
<div class="loading" id="loadingState">Carregando saldos por PDV...</div>
<div class="error" id="errorState"></div>
<div class="saldo-table-wrap">
<h3 class="saldo-table-title">Saldos e Cobertura (menor saldo para maior)</h3>
<div class="saldo-table-container">
<table class="saldo-table">
<thead>
<tr>
<th>PDV</th>
<th>Marca</th>
<th>Saldo</th>
<th>Cobertura</th>
</tr>
</thead>
<tbody id="saldoTableBody">
<tr><td colspan="4">Carregando...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</main>
<footer>
<p>© 2026 Ginseng Pedidos. Todos os direitos reservados.</p>
</footer>
</div>
<script>
let pdvData = [];
function formatCurrency(value) {
return new Intl.NumberFormat('pt-BR', { style: 'currency', currency: 'BRL' }).format(Number(value) || 0);
}
function byId(id) { return document.getElementById(id); }
function getSelectedValues() {
return { origem: byId('pdvOrigem').value, destino: byId('pdvDestino').value };
}
function getPdvItem(key) {
return pdvData.find((item) => String(item.key) === String(key));
}
function clamp(value, min, max) {
return Math.min(max, Math.max(min, value));
}
function getCoverageClass(cobertura) {
const val = Number(cobertura) || 0;
if ((val >= 55 && val <= 60)) return 'coverage-green';
if ((val >= 45 && val < 55) || (val > 60 && val <= 65)) return 'coverage-yellow';
return 'coverage-red';
}
function renderSaldoTable() {
const tbody = byId('saldoTableBody');
const data = pdvData
.slice()
.sort((a, b) => (Number(a.orcamento_disponivel) || 0) - (Number(b.orcamento_disponivel) || 0));
if (!data.length) {
tbody.innerHTML = '<tr><td colspan="4">Nenhum registro para exibir.</td></tr>';
return;
}
tbody.innerHTML = data.map((item) => {
const saldoNum = Number(item.orcamento_disponivel) || 0;
const saldo = formatCurrency(saldoNum);
const saldoClass = saldoNum < 0 ? 'saldo-negative' : '';
const cobertura = Math.round(Number(item.cobertura_dias) || 0);
const coberturaClass = getCoverageClass(cobertura);
return `
<tr>
<td>${item.pdv || '-'}</td>
<td>${item.marca || '-'}</td>
<td class="${saldoClass}">${saldo}</td>
<td class="${coberturaClass}">${cobertura}</td>
</tr>
`;
}).join('');
}
function renderCoverage(origemItem, destinoItem) {
const origemEl = byId('coberturaOrigem');
const destinoEl = byId('coberturaDestino');
origemEl.className = 'coverage-value';
destinoEl.className = 'coverage-value';
if (!origemItem || !destinoItem) {
origemEl.textContent = '-';
destinoEl.textContent = '-';
return;
}
const cobOrigem = Number(origemItem.cobertura_dias) || 0;
const cobDestino = Number(destinoItem.cobertura_dias) || 0;
origemEl.textContent = `${origemItem.marca}: ${Math.round(cobOrigem)}`;
destinoEl.textContent = `${destinoItem.marca}: ${Math.round(cobDestino)}`;
origemEl.classList.add(getCoverageClass(cobOrigem));
destinoEl.classList.add(getCoverageClass(cobDestino));
}
function updateOrigemSummary() {
const origemSelecionada = byId('pdvOrigem').value;
const origemItem = getPdvItem(origemSelecionada);
const dataset = origemItem ? [origemItem] : pdvData;
const totals = dataset.reduce((acc, item) => {
const orcamento = Number(item.orcamento_bruto) || 0;
const pendente = Number(item.pendente_api) || 0;
const ignorado = Number(item.pendente_ignorado_api) || 0;
acc.orcamento += orcamento;
acc.pendenteMenosIgnorado += (pendente - ignorado);
acc.saldo += (orcamento - pendente + ignorado);
return acc;
}, { orcamento: 0, pendenteMenosIgnorado: 0, saldo: 0 });
byId('orcamentoMesOrigem').textContent = formatCurrency(totals.orcamento);
byId('pendenteMenosIgnoradoOrigem').textContent = formatCurrency(totals.pendenteMenosIgnorado);
byId('saldoCompostoOrigem').textContent = formatCurrency(totals.saldo);
}
function renderSelectOptions() {
const origem = byId('pdvOrigem');
const destino = byId('pdvDestino');
const origemList = byId('pdvOrigemList');
const destinoList = byId('pdvDestinoList');
const origemOptions = pdvData
.map((item) => {
const saldo = Number(item.orcamento_disponivel) || 0;
const bloqueado = saldo <= 0 ? ' (sem saldo para origem)' : '';
const label = `${item.pdv} | ${item.marca}${item.descricao_pdv ? ` - ${item.descricao_pdv}` : ''}${bloqueado}`;
return `<option value="${item.key}" label="${label}"></option>`;
})
.join('');
const destinoOptions = pdvData
.map((item) => {
const label = `${item.pdv} | ${item.marca}${item.descricao_pdv ? ` - ${item.descricao_pdv}` : ''}`;
return `<option value="${item.key}" label="${label}"></option>`;
})
.join('');
origemList.innerHTML = origemOptions;
destinoList.innerHTML = destinoOptions;
origem.value = '';
destino.value = '';
}
function updateSimulation(source = 'slider') {
const { origem, destino } = getSelectedValues();
const origemItem = getPdvItem(origem);
const destinoItem = getPdvItem(destino);
const saldoOrigemEl = byId('saldoOrigem');
const saldoDestinoEl = byId('saldoDestino');
const infoEl = byId('transferInfo');
const slider = byId('transferSlider');
const sliderMeta = byId('sliderMeta');
const inputSaldoDestinoDesejado = byId('saldoDestinoDesejado');
const inputQuantidadeTransferir = byId('quantidadeTransferir');
if (origemItem && (Number(origemItem.orcamento_disponivel) || 0) <= 0) {
byId('pdvOrigem').value = '';
saldoOrigemEl.textContent = formatCurrency(0);
saldoDestinoEl.textContent = formatCurrency(0);
slider.min = '0';
slider.max = '0';
slider.value = '0';
sliderMeta.textContent = 'Transferência: R$ 0,00';
infoEl.textContent = 'PDV origem com saldo zerado/negativo não pode ser selecionado para transferência.';
renderCoverage(null, null);
updateOrigemSummary();
return;
}
if (!origemItem || !destinoItem || origem === destino) {
saldoOrigemEl.textContent = formatCurrency(0);
saldoDestinoEl.textContent = formatCurrency(0);
slider.min = '0';
slider.max = '0';
slider.value = '0';
if (source !== 'target') inputSaldoDestinoDesejado.value = '';
if (source !== 'transfer') inputQuantidadeTransferir.value = '';
sliderMeta.textContent = 'Transferência: R$ 0,00';
infoEl.textContent = origem && destino && origem === destino
? 'Escolha PDVs diferentes para simular a transferência.'
: 'Selecione duas combinações de PDV + Marca para iniciar a simulação.';
renderCoverage(null, null);
updateOrigemSummary();
return;
}
const saldoOrigem = Number(origemItem.orcamento_disponivel) || 0;
const saldoDestino = Number(destinoItem.orcamento_disponivel) || 0;
const maxTransfer = Math.max(0, saldoOrigem);
slider.min = '0';
slider.max = String(maxTransfer.toFixed(2));
if ((Number(slider.value) || 0) > maxTransfer) {
slider.value = String(maxTransfer.toFixed(2));
}
if (source === 'target') {
const rawTarget = String(inputSaldoDestinoDesejado.value || '').replace(',', '.').trim();
if (rawTarget === '') {
slider.value = '0';
} else {
const targetDestino = Number(rawTarget);
if (Number.isFinite(targetDestino)) {
const transferByTarget = clamp(targetDestino - saldoDestino, 0, maxTransfer);
slider.value = String(transferByTarget.toFixed(2));
}
}
}
if (source === 'transfer') {
const rawTransfer = String(inputQuantidadeTransferir.value || '').replace(',', '.').trim();
if (rawTransfer === '') {
slider.value = '0';
} else {
const transferManual = Number(rawTransfer);
if (Number.isFinite(transferManual)) {
slider.value = String(clamp(transferManual, 0, maxTransfer).toFixed(2));
}
}
}
const transfer = Number(slider.value) || 0;
const origemSimulado = saldoOrigem - transfer;
const destinoSimulado = saldoDestino + transfer;
if (source !== 'target') {
inputSaldoDestinoDesejado.value = destinoSimulado.toFixed(2);
}
if (source !== 'transfer') {
inputQuantidadeTransferir.value = transfer.toFixed(2);
}
saldoOrigemEl.textContent = formatCurrency(origemSimulado);
saldoDestinoEl.textContent = formatCurrency(destinoSimulado);
sliderMeta.textContent = `Transferência: ${formatCurrency(transfer)}`;
infoEl.textContent = `Tirando ${formatCurrency(transfer)} de ${origemItem.pdv} (${origemItem.marca}) e passando para ${destinoItem.pdv} (${destinoItem.marca}).`;
renderCoverage(origemItem, destinoItem);
updateOrigemSummary();
}
async function loadData() {
const loading = byId('loadingState');
const error = byId('errorState');
try {
loading.style.display = 'block';
error.style.display = 'none';
const response = await fetch('/home/api/controle-saldo-data/');
if (!response.ok) throw new Error('Falha ao carregar dados do controle de saldo.');
const result = await response.json();
if (result.status !== 'success') throw new Error(result.message || 'Erro ao carregar dados.');
pdvData = Array.isArray(result.data) ? result.data : [];
renderSelectOptions();
updateSimulation('slider');
updateOrigemSummary();
byId('pdvOrigem').addEventListener('input', () => updateSimulation('slider'));
byId('pdvDestino').addEventListener('input', () => updateSimulation('slider'));
byId('transferSlider').addEventListener('input', () => updateSimulation('slider'));
byId('saldoDestinoDesejado').addEventListener('input', (event) => {
const onlyDotNumber = String(event.target.value || '').replace(',', '.').replace(/[^\d.]/g, '');
event.target.value = onlyDotNumber;
updateSimulation('target');
});
byId('quantidadeTransferir').addEventListener('input', (event) => {
const onlyDotNumber = String(event.target.value || '').replace(',', '.').replace(/[^\d.]/g, '');
event.target.value = onlyDotNumber;
updateSimulation('transfer');
});
byId('btnLimparDados').addEventListener('click', () => {
byId('pdvOrigem').value = '';
byId('pdvDestino').value = '';
byId('saldoDestinoDesejado').value = '';
byId('quantidadeTransferir').value = '';
byId('transferSlider').value = '0';
renderSelectOptions();
renderSaldoTable();
updateSimulation('slider');
});
renderSaldoTable();
loading.style.display = 'none';
} catch (e) {
loading.style.display = 'none';
error.textContent = e.message || 'Erro inesperado ao carregar dados.';
error.style.display = 'block';
}
}
function initializeEvents() {
const toggleSidebar = byId('toggleSidebar');
const sidebar = byId('sidebar');
const backdrop = byId('backdrop');
toggleSidebar.addEventListener('click', () => {
sidebar.classList.toggle('open');
backdrop.classList.toggle('show');
});
backdrop.addEventListener('click', () => {
sidebar.classList.remove('open');
backdrop.classList.remove('show');
});
document.querySelectorAll('.sidebar-item').forEach((item) => {
item.addEventListener('click', () => {
sidebar.classList.remove('open');
backdrop.classList.remove('show');
});
});
}
document.addEventListener('DOMContentLoaded', () => {
initializeEvents();
loadData();
});
</script>
</body>
</html>

View File

@ -203,7 +203,7 @@
/* ===== CARDS ===== */ /* ===== CARDS ===== */
.cards-row { .cards-row {
display: grid; display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)); grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 16px; gap: 16px;
} }
.cards-row-primary { .cards-row-primary {
@ -214,7 +214,7 @@
background: var(--white); background: var(--white);
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 12px; border-radius: 12px;
padding: 16px; padding: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
transition: all 0.2s ease; transition: all 0.2s ease;
} }
@ -232,7 +232,7 @@
.card-value { .card-value {
margin: 0; margin: 0;
font-size: 24px; font-size: 21px;
font-weight: 700; font-weight: 700;
color: var(--text-primary); color: var(--text-primary);
} }
@ -587,6 +587,13 @@
</svg> </svg>
Relatórios Relatórios
</a> </a>
<a class="sidebar-item" href="/home/controle-saldo/" title="Controle de Saldo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M4 7h16M4 12h10M4 17h16" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M16 10l4 2-4 2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
Controle de Saldo
</a>
</nav> </nav>
</aside> </aside>
@ -720,6 +727,10 @@
<p class="card-title">Cobertura projetada com compra</p> <p class="card-title">Cobertura projetada com compra</p>
<p class="card-value" id="coberturaProjetadaCompra">-</p> <p class="card-value" id="coberturaProjetadaCompra">-</p>
</div> </div>
<div class="card">
<p class="card-title">Aprovado Hoje</p>
<p class="card-value" id="aprovadoHojeCard">-</p>
</div>
</section> </section>
<!-- PIVOT TABLE --> <!-- PIVOT TABLE -->
@ -744,6 +755,7 @@
<script> <script>
let rawPivotData = []; let rawPivotData = [];
let aprovadoHojeTotal = 0;
let filterRenderTimer = null; let filterRenderTimer = null;
let pivotPluginUnavailableLogged = false; let pivotPluginUnavailableLogged = false;
let apiFilterLoadTimer = null; let apiFilterLoadTimer = null;
@ -889,6 +901,7 @@
const data = result.data; const data = result.data;
rawPivotData = Array.isArray(data) ? data : []; rawPivotData = Array.isArray(data) ? data : [];
aprovadoHojeTotal = Number(result.aprovado_hoje_total) || 0;
populateCategoriaDropdown(result.categorias || []); populateCategoriaDropdown(result.categorias || []);
populateClasseDropdown(result.classes || []); populateClasseDropdown(result.classes || []);
populateCicloDropdown(result.ciclos || [], cicloSelecionado); populateCicloDropdown(result.ciclos || [], cicloSelecionado);
@ -1080,7 +1093,7 @@
}); });
const groupedSummary = aggregateSummaryRows(filtered); const groupedSummary = aggregateSummaryRows(filtered);
updateDashboardCards(groupedSummary); updateDashboardCards(groupedSummary, aprovadoHojeTotal);
filterRenderTimer = setTimeout(() => { filterRenderTimer = setTimeout(() => {
requestAnimationFrame(() => initializePivotTable(groupedSummary)); requestAnimationFrame(() => initializePivotTable(groupedSummary));
@ -1165,7 +1178,7 @@
return Array.from(orcamentoPorPdv.values()).reduce((acc, val) => acc + val, 0); return Array.from(orcamentoPorPdv.values()).reduce((acc, val) => acc + val, 0);
} }
function updateDashboardCards(data) { function updateDashboardCards(data, aprovadoHoje = 0) {
const rows = Array.isArray(data) ? data : []; const rows = Array.isArray(data) ? data : [];
const estoqueGeral = rows.reduce((acc, row) => { const estoqueGeral = rows.reduce((acc, row) => {
const val = Number(row.Estoque_Total); const val = Number(row.Estoque_Total);
@ -1207,6 +1220,7 @@
const coberturaEstTransCard = document.getElementById('coberturaEstTrans'); const coberturaEstTransCard = document.getElementById('coberturaEstTrans');
const coberturaEstTransPendCard = document.getElementById('coberturaEstTransPend'); const coberturaEstTransPendCard = document.getElementById('coberturaEstTransPend');
const coberturaProjetadaCompraCard = document.getElementById('coberturaProjetadaCompra'); const coberturaProjetadaCompraCard = document.getElementById('coberturaProjetadaCompra');
const aprovadoHojeCard = document.getElementById('aprovadoHojeCard');
if (estoqueCard) estoqueCard.textContent = formatNumberPtBr(estoqueGeral); if (estoqueCard) estoqueCard.textContent = formatNumberPtBr(estoqueGeral);
if (transitoCard) transitoCard.textContent = formatNumberPtBr(transitoGeral); if (transitoCard) transitoCard.textContent = formatNumberPtBr(transitoGeral);
@ -1218,6 +1232,7 @@
if (coberturaEstTransCard) coberturaEstTransCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransGeral)); if (coberturaEstTransCard) coberturaEstTransCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransGeral));
if (coberturaEstTransPendCard) coberturaEstTransPendCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransPendGeral)); if (coberturaEstTransPendCard) coberturaEstTransPendCard.textContent = formatNumberPtBr(Math.trunc(coberturaEstTransPendGeral));
if (coberturaProjetadaCompraCard) coberturaProjetadaCompraCard.textContent = formatNumberPtBr(Math.trunc(coberturaProjetadaCompraGeral)); if (coberturaProjetadaCompraCard) coberturaProjetadaCompraCard.textContent = formatNumberPtBr(Math.trunc(coberturaProjetadaCompraGeral));
if (aprovadoHojeCard) aprovadoHojeCard.textContent = formatCurrencyPtBr(aprovadoHoje);
} }
function populateCicloDropdown(ciclos, selectedCiclo = '') { function populateCicloDropdown(ciclos, selectedCiclo = '') {

View File

@ -7,6 +7,8 @@ urlpatterns = [
# Esta app é montada em /home/, mas também há um path('/') no projeto # Esta app é montada em /home/, mas também há um path('/') no projeto
# principal para renderizar a mesma página inicial. # principal para renderizar a mesma página inicial.
path("", views.home_page, name="home_page"), path("", views.home_page, name="home_page"),
path("controle-saldo/", views.controle_saldo_page, name="controle_saldo_page"),
path("api/table-data/", views.get_table_data, name="table_data"), path("api/table-data/", views.get_table_data, name="table_data"),
path("api/pivot-data/", views.get_pivot_data, name="pivot_data"), path("api/pivot-data/", views.get_pivot_data, name="pivot_data"),
path("api/controle-saldo-data/", views.get_controle_saldo_data, name="controle_saldo_data"),
] ]

View File

@ -2,6 +2,7 @@ from django.shortcuts import render
from django.http import JsonResponse from django.http import JsonResponse
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.utils import timezone
import logging import logging
import pyodbc import pyodbc
import requests import requests
@ -34,6 +35,396 @@ def home_page(request):
return render(request, "home/home_page.html", context) return render(request, "home/home_page.html", context)
def controle_saldo_page(request):
"""Página simulador de transferência de saldo entre PDVs."""
context = {
'user_email': request.user.email if request.user.is_authenticated else '',
'is_admin': request.user.is_staff if request.user.is_authenticated else False,
}
return render(request, "home/controle_saldo.html", context)
def get_controle_saldo_data(request):
"""Retorna saldo disponível por PDV para simulação de transferência."""
try:
saldo_orcamento_por_pdv_marca = get_api_orcamento_saldo_por_pdv_marca()
pending_por_pdv_marca = get_api_pendingorder_por_pdv_marca()
pending_ignorado_por_pdv_marca = get_api_pendingorder_ignorados_por_pdv_marca()
cobertura_por_pdv_marca = get_home_cobertura_por_pdv_marca()
base_pdvs_data = get_api_base_pdvs_data() or []
descricao_por_pdv: Dict[str, str] = {}
for row in base_pdvs_data:
pdv = _normalize_pdv(
row.get('pdv', row.get('PDV', row.get('loja_id', row.get('LOJA_ID', ''))))
)
if not pdv:
continue
descricao = str(
row.get(
'descricao_pdv',
row.get(
'DESCRICAO_PDV',
row.get('descricao', row.get('DESCRICAO', row.get('nome', row.get('NOME', ''))))
)
)
or ''
).strip()
if descricao:
descricao_por_pdv[pdv] = descricao
chaves_unicas = (
set(saldo_orcamento_por_pdv_marca.keys())
| set(pending_por_pdv_marca.keys())
| set(pending_ignorado_por_pdv_marca.keys())
)
pdvs = []
for key in chaves_unicas:
pdv, marca = key.split('|', 1)
saldo = float(saldo_orcamento_por_pdv_marca.get(key, 0.0))
pending = float(pending_por_pdv_marca.get(key, 0.0))
pending_ignorado = float(pending_ignorado_por_pdv_marca.get(key, 0.0))
orcamento_disponivel = saldo - pending + pending_ignorado
pdvs.append({
'key': key,
'pdv': pdv,
'marca': marca,
'descricao_pdv': descricao_por_pdv.get(pdv, ''),
'orcamento_disponivel': orcamento_disponivel,
'cobertura_dias': float(cobertura_por_pdv_marca.get(key, 0.0)),
'orcamento_bruto': saldo,
'pendente_api': pending,
'pendente_ignorado_api': pending_ignorado,
})
pdvs.sort(key=lambda item: (
(item.get('descricao_pdv') or '').lower(),
(item.get('pdv') or ''),
(item.get('marca') or ''),
))
return JsonResponse({
'status': 'success',
'data': pdvs,
'count': len(pdvs),
})
except Exception as e:
logger.exception("Erro ao carregar dados de controle de saldo")
return JsonResponse({
'status': 'error',
'message': str(e),
'data': []
}, status=200)
def get_home_cobertura_por_pdv() -> Dict[str, float]:
"""Calcula cobertura (dias) agregada por PDV usando a mesma base SQL do /home."""
try:
cache_key = "home_cobertura_pdv"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
query = """
WITH draft as (
SELECT
dh.businessunit AS MARCA,
dh.loja_id AS PDV,
dh.code AS SKU,
dh.stock_actual AS ESTOQUE,
dh.pendingorder AS PENDENTE,
dh.stock_intransit AS TRANSITO
FROM draft_historico dh with(nolock)
WHERE dh.data = (SELECT MAX(data) FROM draft_historico)
)
SELECT
emh.PDV,
emh.SKU,
emh.[DDV PREVISTO] AS DDV_PREVISTO,
COALESCE(d.ESTOQUE, emh.[ESTOQUE ATUAL], 0) AS ESTOQUE,
COALESCE(d.TRANSITO, emh.[ESTOQUE EM TRANSITO], 0) AS TRANSITO,
COALESCE(d.PENDENTE, emh.[PEDIDO PENDENTE], 0) AS PENDENTE
FROM estoque_mar_historico emh with(nolock)
LEFT JOIN draft d on d.PDV = emh.PDV and d.SKU = emh.SKU
WHERE emh.data_estoque = (select max(data_estoque) from estoque_mar_historico with(nolock))
and emh.CATEGORIA not in ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
and emh.pdv not in ('23703')
"""
with get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(query)
columns = [col[0] for col in cursor.description]
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
agg: Dict[str, Dict[str, float]] = {}
for row in rows:
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
if not pdv:
continue
if pdv not in agg:
agg[pdv] = {'estoque': 0.0, 'ddv': 0.0}
estoque = _parse_float(row.get('ESTOQUE', row.get('estoque', 0)))
ddv = _parse_float(row.get('DDV_PREVISTO', row.get('ddv_previsto', 0)))
agg[pdv]['estoque'] += estoque
agg[pdv]['ddv'] += ddv
cobertura_por_pdv: Dict[str, float] = {}
for pdv, vals in agg.items():
ddv = vals['ddv']
cobertura = (vals['estoque'] / ddv) if ddv > 0 else 0.0
cobertura_por_pdv[pdv] = round(cobertura, 0)
cache.set(cache_key, cobertura_por_pdv, timeout=300)
return cobertura_por_pdv
except Exception as e:
logger.error("Erro ao processar cobertura SQL do /home (PDV): %s", e)
return {}
def get_home_cobertura_por_pdv_marca() -> Dict[str, float]:
"""Calcula cobertura (dias) por PDV+MARCA usando a mesma base SQL do /home."""
try:
cache_key = "home_cobertura_pdv_marca"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
query = """
WITH draft as (
SELECT
dh.businessunit AS MARCA,
dh.loja_id AS PDV,
dh.code AS SKU,
dh.stock_actual AS ESTOQUE,
dh.pendingorder AS PENDENTE,
dh.stock_intransit AS TRANSITO
FROM draft_historico dh with(nolock)
WHERE dh.data = (SELECT MAX(data) FROM draft_historico)
)
SELECT
CASE
WHEN emh.DESCRICAO LIKE '%OUI%' THEN 'OUI'
ELSE emh.ORIGEM
END AS MARCA,
emh.PDV,
emh.SKU,
emh.[DDV PREVISTO] AS DDV_PREVISTO,
COALESCE(d.ESTOQUE, emh.[ESTOQUE ATUAL], 0) AS ESTOQUE,
COALESCE(d.TRANSITO, emh.[ESTOQUE EM TRANSITO], 0) AS TRANSITO,
COALESCE(d.PENDENTE, emh.[PEDIDO PENDENTE], 0) AS PENDENTE
FROM estoque_mar_historico emh with(nolock)
LEFT JOIN draft d on d.PDV = emh.PDV and d.SKU = emh.SKU
WHERE emh.data_estoque = (select max(data_estoque) from estoque_mar_historico with(nolock))
and emh.CATEGORIA not in ('SUPORTE À VENDA','SUPORTE A VENDA', 'EMBALAGENS')
and emh.pdv not in ('23703')
"""
with get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute(query)
columns = [col[0] for col in cursor.description]
rows = [dict(zip(columns, row)) for row in cursor.fetchall()]
agg: Dict[str, Dict[str, float]] = {}
for row in rows:
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
marca = _normalize_marca(row.get('MARCA', row.get('marca', '')))
if not pdv or not marca:
continue
key = f"{pdv}|{marca}"
if key not in agg:
agg[key] = {'estoque': 0.0, 'transito': 0.0, 'pendente': 0.0, 'ddv': 0.0}
estoque = _parse_float(row.get('ESTOQUE', row.get('estoque', 0)))
transito = _parse_float(row.get('TRANSITO', row.get('transito', 0)))
pendente = _parse_float(row.get('PENDENTE', row.get('pendente', 0)))
ddv = _parse_float(row.get('DDV_PREVISTO', row.get('ddv_previsto', 0)))
agg[key]['estoque'] += estoque
agg[key]['transito'] += transito
agg[key]['pendente'] += pendente
agg[key]['ddv'] += ddv
cobertura_por_pdv_marca: Dict[str, float] = {}
for key, vals in agg.items():
ddv = vals['ddv']
# No controle de saldo exibimos cobertura atual (estoque / ddv),
# alinhada com a validação manual solicitada.
cobertura = (vals['estoque'] / ddv) if ddv > 0 else 0.0
cobertura_por_pdv_marca[key] = round(cobertura, 0)
cache.set(cache_key, cobertura_por_pdv_marca, timeout=300)
return cobertura_por_pdv_marca
except Exception as e:
logger.error("Erro ao processar cobertura SQL do /home (PDV+MARCA): %s", e)
return {}
def get_api_orcamento_saldo_por_pdv_marca(mes: Optional[int] = None) -> Dict[str, float]:
"""Busca saldo_pdv por PDV+MARCA na API de orçamento, filtrando mês atual (ou informado)."""
try:
mes_alvo = int(mes or datetime.now().month)
cache_key = f"api_orcamento_saldo_pdv_marca:mes={mes_alvo}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
token = api_config.get('TOKEN')
url = 'https://api.grupoginseng.com.br/api/vw_orcamento_vs_notas_2026?limit=50000'
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
response = requests.get(url, headers=headers, timeout=30)
if response.status_code != 200:
logger.error("Erro ao chamar API orçamento (PDV+MARCA): %s", response.status_code)
return {}
payload = response.json()
rows = payload.get('data') if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return {}
saldo_por_pdv_marca: Dict[str, float] = {}
for row in rows:
try:
mes_row = int(_parse_float(row.get('mes')))
except Exception:
continue
if mes_row != mes_alvo:
continue
pdv = _normalize_pdv(row.get('pdv', row.get('PDV', row.get('loja_id', row.get('LOJA_ID', '')))))
marca = _normalize_marca(row.get('brand', row.get('BRAND', row.get('marca', row.get('MARCA', '')))))
if not pdv or not marca:
continue
saldo = _parse_float(row.get('saldo_pdv', row.get('SALDO_PDV', 0)))
key = f"{pdv}|{marca}"
saldo_por_pdv_marca[key] = saldo_por_pdv_marca.get(key, 0.0) + saldo
cache.set(cache_key, saldo_por_pdv_marca, timeout=300)
return saldo_por_pdv_marca
except requests.exceptions.RequestException as e:
logger.error("Erro de conexão com API orçamento (PDV+MARCA): %s", e)
return {}
except Exception as e:
logger.error("Erro ao processar API orçamento (PDV+MARCA): %s", e)
return {}
def get_api_pendingorder_por_pdv_marca() -> Dict[str, float]:
"""Busca pendingorder por PDV+MARCA na API e agrega por loja/marca."""
try:
cache_key = "api_pendingorder_pdv_marca"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
token = api_config.get('TOKEN')
url = 'https://api.grupoginseng.com.br/api/vw_pendingorder_draft?limit=50000'
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
response = requests.get(url, headers=headers, timeout=30)
if response.status_code != 200:
logger.error("Erro ao chamar API pendingorder (PDV+MARCA): %s", response.status_code)
return {}
payload = response.json()
rows = payload.get('data') if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return {}
pending_por_pdv_marca: Dict[str, float] = {}
for row in rows:
pdv = _normalize_pdv(row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', '')))))
marca = _normalize_marca(row.get('businessunit', row.get('BUSINESSUNIT', row.get('marca', row.get('MARCA', '')))))
if not pdv or not marca:
continue
pending = _parse_float(
row.get(
'total_pricesellin',
row.get('TOTAL_PRICESELLIN', row.get('pendingorder', row.get('PENDINGORDER', row.get('pendente', 0))))
)
)
key = f"{pdv}|{marca}"
pending_por_pdv_marca[key] = pending_por_pdv_marca.get(key, 0.0) + pending
cache.set(cache_key, pending_por_pdv_marca, timeout=300)
return pending_por_pdv_marca
except requests.exceptions.RequestException as e:
logger.error("Erro de conexão com API pendingorder (PDV+MARCA): %s", e)
return {}
except Exception as e:
logger.error("Erro ao processar API pendingorder (PDV+MARCA): %s", e)
return {}
def get_api_pendingorder_ignorados_por_pdv_marca() -> Dict[str, float]:
"""Busca pendingorder ignorado por PDV+MARCA na API e agrega por loja/marca."""
try:
cache_key = "api_pendingorder_ignorados_pdv_marca"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
token = api_config.get('TOKEN')
url = 'https://api.grupoginseng.com.br/api/vw_pendingorder_ignorados?limit=50000'
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
response = requests.get(url, headers=headers, timeout=30)
if response.status_code != 200:
logger.error("Erro ao chamar API pendingorder ignorados (PDV+MARCA): %s", response.status_code)
return {}
payload = response.json()
rows = payload.get('data') if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return {}
pending_ignorado_por_pdv_marca: Dict[str, float] = {}
for row in rows:
pdv = _normalize_pdv(
row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', ''))))
)
marca = _normalize_marca(row.get('businessunit', row.get('BUSINESSUNIT', row.get('marca', row.get('MARCA', '')))))
if not pdv or not marca:
continue
pending_ignorado = _parse_float(
row.get(
'total_ignorado',
row.get(
'TOTAL_IGNORADO',
row.get(
'total_pricesellin',
row.get(
'TOTAL_PRICESELLIN',
row.get(
'pendingorder',
row.get('PENDINGORDER', row.get('pendente', 0))
)
)
)
)
)
)
key = f"{pdv}|{marca}"
pending_ignorado_por_pdv_marca[key] = pending_ignorado_por_pdv_marca.get(key, 0.0) + pending_ignorado
cache.set(cache_key, pending_ignorado_por_pdv_marca, timeout=300)
return pending_ignorado_por_pdv_marca
except requests.exceptions.RequestException as e:
logger.error("Erro de conexão com API pendingorder ignorados (PDV+MARCA): %s", e)
return {}
except Exception as e:
logger.error("Erro ao processar API pendingorder ignorados (PDV+MARCA): %s", e)
return {}
def get_table_data(request): def get_table_data(request):
"""API endpoint que retorna os dados da query em formato JSON.""" """API endpoint que retorna os dados da query em formato JSON."""
try: try:
@ -41,7 +432,7 @@ def get_table_data(request):
WITH draft as ( WITH draft as (
SELECT SELECT
dh.data, dh.data,
dh.businessunit AS MARCA, CASE WHEN dh.description like '%OUI%' then 'OUI' else businessunit end as MARCA,
dh.loja_id AS PDV, dh.loja_id AS PDV,
dh.code AS SKU, dh.code AS SKU,
dh.description AS DESCRICAO_PRODUTO, dh.description AS DESCRICAO_PRODUTO,
@ -364,6 +755,144 @@ def get_api_pendingorder_por_pdv() -> Dict[str, float]:
return {} return {}
def get_api_pendingorder_ignorados_por_pdv() -> Dict[str, float]:
"""Busca pendingorder ignorado por PDV na API e agrega por loja."""
try:
cache_key = "api_pendingorder_ignorados_pdv"
cached_data = cache.get(cache_key)
if cached_data is not None:
return cached_data
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
token = api_config.get('TOKEN')
url = 'https://api.grupoginseng.com.br/api/vw_pendingorder_ignorados?limit=50000'
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
response = requests.get(url, headers=headers, timeout=30)
if response.status_code != 200:
logger.error("Erro ao chamar API pendingorder ignorados: %s", response.status_code)
return {}
payload = response.json()
rows = payload.get('data') if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return {}
pending_ignorado_por_pdv: Dict[str, float] = {}
for row in rows:
pdv = _normalize_pdv(
row.get('loja_id', row.get('LOJA_ID', row.get('pdv', row.get('PDV', ''))))
)
if not pdv:
continue
pending_ignorado = _parse_float(
row.get(
'total_ignorado',
row.get(
'TOTAL_IGNORADO',
row.get(
'total_pricesellin',
row.get(
'TOTAL_PRICESELLIN',
row.get(
'pendingorder',
row.get('PENDINGORDER', row.get('pendente', 0))
)
)
)
)
)
)
pending_ignorado_por_pdv[pdv] = pending_ignorado_por_pdv.get(pdv, 0.0) + pending_ignorado
cache.set(cache_key, pending_ignorado_por_pdv, timeout=300)
return pending_ignorado_por_pdv
except requests.exceptions.RequestException as e:
logger.error("Erro de conexão com API pendingorder ignorados: %s", e)
return {}
except Exception as e:
logger.error("Erro ao processar API pendingorder ignorados: %s", e)
return {}
def get_api_aprovado_hoje_total() -> float:
"""Soma o valor aprovado no dia atual (dtimplantacao == hoje) na API de detalhepedido."""
try:
hoje = timezone.localdate()
cache_key = f"api_aprovado_hoje_total:{hoje.isoformat()}"
cached_data = cache.get(cache_key)
if cached_data is not None:
return float(cached_data)
api_config = getattr(settings, 'API_SUPRIMENTOS_CONFIG', {})
token = api_config.get('TOKEN')
url = 'https://api.grupoginseng.com.br/api/suprimentos_detalhepedido?limit=50000&status=aprovado'
headers = {'Content-Type': 'application/json'}
if token:
headers['Authorization'] = f'Bearer {token}'
response = requests.get(url, headers=headers, timeout=30)
if response.status_code != 200:
logger.error("Erro ao chamar API aprovados de hoje: %s", response.status_code)
return 0.0
payload = response.json()
rows = payload.get('data') if isinstance(payload, dict) else payload
if not isinstance(rows, list):
return 0.0
def _is_row_hoje(dt_value) -> bool:
if dt_value in (None, ''):
return False
text = str(dt_value).strip()
if not text:
return False
iso_candidate = text.replace('Z', '+00:00')
try:
dt = datetime.fromisoformat(iso_candidate)
return dt.date() == hoje
except Exception:
pass
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y-%m-%d %H:%M:%S", "%d/%m/%Y %H:%M:%S"):
try:
return datetime.strptime(text, fmt).date() == hoje
except Exception:
continue
return False
total_aprovado_hoje = 0.0
for row in rows:
if not _is_row_hoje(row.get('dtimplantacao', row.get('DTIMPLANTACAO'))):
continue
valor = _parse_float(
row.get(
'total_pricesellin',
row.get(
'TOTAL_PRICESELLIN',
row.get(
'total_ignorado',
row.get(
'TOTAL_IGNORADO',
row.get('valor_total', row.get('VALOR_TOTAL', 0))
)
)
)
)
)
total_aprovado_hoje += valor
cache.set(cache_key, total_aprovado_hoje, timeout=300)
return total_aprovado_hoje
except requests.exceptions.RequestException as e:
logger.error("Erro de conexão com API aprovados de hoje: %s", e)
return 0.0
except Exception as e:
logger.error("Erro ao processar API aprovados de hoje: %s", e)
return 0.0
def _parse_float(value) -> float: def _parse_float(value) -> float:
"""Converte valores numéricos aceitando formatos BR/EN (ex.: 1.234,56 / 1,234.56).""" """Converte valores numéricos aceitando formatos BR/EN (ex.: 1.234,56 / 1,234.56)."""
if value in (None, ''): if value in (None, ''):
@ -434,6 +963,14 @@ def _normalize_pdv(value) -> str:
return text or '0' return text or '0'
def _normalize_marca(value) -> str:
"""Normaliza marca para comparação entre fontes diferentes."""
text = str(value or '').strip()
if not text:
return ''
return text.upper()
def _normalize_sku(value) -> str: def _normalize_sku(value) -> str:
"""Normaliza SKU para comparação entre fontes diferentes.""" """Normaliza SKU para comparação entre fontes diferentes."""
text = str(value or '').strip() text = str(value or '').strip()
@ -596,18 +1133,22 @@ def merge_orcamento_with_summary_data(
summary_rows: List[Dict], summary_rows: List[Dict],
saldo_por_pdv: Dict[str, float], saldo_por_pdv: Dict[str, float],
pending_por_pdv: Optional[Dict[str, float]] = None, pending_por_pdv: Optional[Dict[str, float]] = None,
pending_ignorado_por_pdv: Optional[Dict[str, float]] = None,
) -> List[Dict]: ) -> List[Dict]:
"""Anexa orçamento disponível por PDV às linhas resumidas, descontando pendingorder.""" """Anexa orçamento disponível por PDV às linhas resumidas, descontando pendingorder."""
if not summary_rows: if not summary_rows:
return summary_rows return summary_rows
pending_por_pdv = pending_por_pdv or {} pending_por_pdv = pending_por_pdv or {}
pending_ignorado_por_pdv = pending_ignorado_por_pdv or {}
for row in summary_rows: for row in summary_rows:
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', ''))) pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
saldo = float(saldo_por_pdv.get(pdv, 0.0)) saldo = float(saldo_por_pdv.get(pdv, 0.0))
pending = float(pending_por_pdv.get(pdv, 0.0)) pending = float(pending_por_pdv.get(pdv, 0.0))
pending_ignorado = float(pending_ignorado_por_pdv.get(pdv, 0.0))
row['Orcamento_Bruto'] = saldo row['Orcamento_Bruto'] = saldo
row['Pendente_API'] = pending row['Pendente_API'] = pending
row['Orcamento_Disponivel'] = saldo - pending row['Pendente_Ignorado_API'] = pending_ignorado
row['Orcamento_Disponivel'] = saldo - pending + pending_ignorado
return summary_rows return summary_rows
@ -783,23 +1324,29 @@ ORDER BY pa.[DESCRIÇÃO];
summary_rows = aggregate_rows_for_dashboard(rows) summary_rows = aggregate_rows_for_dashboard(rows)
saldo_orcamento_por_pdv = get_api_orcamento_saldo_por_pdv() saldo_orcamento_por_pdv = get_api_orcamento_saldo_por_pdv()
pending_por_pdv = get_api_pendingorder_por_pdv() pending_por_pdv = get_api_pendingorder_por_pdv()
pending_ignorado_por_pdv = get_api_pendingorder_ignorados_por_pdv()
summary_rows = merge_orcamento_with_summary_data( summary_rows = merge_orcamento_with_summary_data(
summary_rows, summary_rows,
saldo_orcamento_por_pdv, saldo_orcamento_por_pdv,
pending_por_pdv, pending_por_pdv,
pending_ignorado_por_pdv,
) )
aprovado_hoje_total = get_api_aprovado_hoje_total()
logger.info( logger.info(
"Pivot carregada | linhas_sql=%s linhas_resumo=%s pdvs_orcamento=%s pdvs_pending=%s", "Pivot carregada | linhas_sql=%s linhas_resumo=%s pdvs_orcamento=%s pdvs_pending=%s pdvs_pending_ignorados=%s aprovado_hoje_total=%s",
len(rows), len(rows),
len(summary_rows), len(summary_rows),
len(saldo_orcamento_por_pdv), len(saldo_orcamento_por_pdv),
len(pending_por_pdv), len(pending_por_pdv),
len(pending_ignorado_por_pdv),
round(aprovado_hoje_total, 2),
) )
return JsonResponse({ return JsonResponse({
'status': 'success', 'status': 'success',
'data': summary_rows, 'data': summary_rows,
'count': len(summary_rows), 'count': len(summary_rows),
'aprovado_hoje_total': aprovado_hoje_total,
'api_integrated': bool(api_data), 'api_integrated': bool(api_data),
'base_pdvs_integrated': bool(base_pdvs_data), 'base_pdvs_integrated': bool(base_pdvs_data),
'orcamento_integrated': bool(saldo_orcamento_por_pdv), 'orcamento_integrated': bool(saldo_orcamento_por_pdv),