ajustes de funcionalidade
This commit is contained in:
parent
ab1445574f
commit
f75926bc3c
967
home/templates/home/controle_saldo.html
Normal file
967
home/templates/home/controle_saldo.html
Normal 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>
|
||||
@ -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 = '') {
|
||||
|
||||
@ -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"),
|
||||
]
|
||||
|
||||
553
home/views.py
553
home/views.py
@ -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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user