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

View File

@ -7,6 +7,8 @@ urlpatterns = [
# Esta app é montada em /home/, mas também há um path('/') no projeto
# principal para renderizar a mesma página inicial.
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/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.conf import settings
from django.core.cache import cache
from django.utils import timezone
import logging
import pyodbc
import requests
@ -34,6 +35,396 @@ def home_page(request):
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):
"""API endpoint que retorna os dados da query em formato JSON."""
try:
@ -41,7 +432,7 @@ def get_table_data(request):
WITH draft as (
SELECT
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.code AS SKU,
dh.description AS DESCRICAO_PRODUTO,
@ -364,6 +755,144 @@ def get_api_pendingorder_por_pdv() -> Dict[str, float]:
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:
"""Converte valores numéricos aceitando formatos BR/EN (ex.: 1.234,56 / 1,234.56)."""
if value in (None, ''):
@ -434,6 +963,14 @@ def _normalize_pdv(value) -> str:
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:
"""Normaliza SKU para comparação entre fontes diferentes."""
text = str(value or '').strip()
@ -596,18 +1133,22 @@ def merge_orcamento_with_summary_data(
summary_rows: List[Dict],
saldo_por_pdv: Dict[str, float],
pending_por_pdv: Optional[Dict[str, float]] = None,
pending_ignorado_por_pdv: Optional[Dict[str, float]] = None,
) -> List[Dict]:
"""Anexa orçamento disponível por PDV às linhas resumidas, descontando pendingorder."""
if not summary_rows:
return summary_rows
pending_por_pdv = pending_por_pdv or {}
pending_ignorado_por_pdv = pending_ignorado_por_pdv or {}
for row in summary_rows:
pdv = _normalize_pdv(row.get('PDV', row.get('pdv', '')))
saldo = float(saldo_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['Pendente_API'] = pending
row['Orcamento_Disponivel'] = saldo - pending
row['Pendente_Ignorado_API'] = pending_ignorado
row['Orcamento_Disponivel'] = saldo - pending + pending_ignorado
return summary_rows
@ -783,23 +1324,29 @@ ORDER BY pa.[DESCRIÇÃO];
summary_rows = aggregate_rows_for_dashboard(rows)
saldo_orcamento_por_pdv = get_api_orcamento_saldo_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,
saldo_orcamento_por_pdv,
pending_por_pdv,
pending_ignorado_por_pdv,
)
aprovado_hoje_total = get_api_aprovado_hoje_total()
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(summary_rows),
len(saldo_orcamento_por_pdv),
len(pending_por_pdv),
len(pending_ignorado_por_pdv),
round(aprovado_hoje_total, 2),
)
return JsonResponse({
'status': 'success',
'data': summary_rows,
'count': len(summary_rows),
'aprovado_hoje_total': aprovado_hoje_total,
'api_integrated': bool(api_data),
'base_pdvs_integrated': bool(base_pdvs_data),
'orcamento_integrated': bool(saldo_orcamento_por_pdv),