Add 'fiscal/' from commit '102ba84500dbd9655a0471288c3e278652593ca9'

git-subtree-dir: fiscal
git-subtree-mainline: d95165fc8f
git-subtree-split: 102ba84500
This commit is contained in:
gti
2026-09-29 14:43:23 -03:00
868 changed files with 566394 additions and 0 deletions
@@ -0,0 +1,54 @@
{% extends "base.html" %}
{% block title %}{{ detail.display_name or detail.hostname or "Agent" }} | GTI Fiscal{% endblock %}
{% block content %}
<div class="detail-head"><div><p class="eyebrow">{{ detail.client or "Cliente não identificado" }} / INSTALAÇÃO</p><h1>{{ detail.display_name or detail.hostname or detail.installation_id }}</h1><p class="muted">{{ detail.hostname or "hostname indisponível" }}</p></div><div class="status-stack"><div class="status-block"><small class="status-label">Saúde técnica</small><span class="badge {{ detail.technical_health|lower }} large">{{ detail.technical_health_label }}</span></div><div class="status-block"><small class="status-label">Status fiscal</small><span class="badge fiscal-{{ detail.fiscal_status|lower }} large">{{ detail.fiscal_status_label }}</span></div></div></div>
<section class="cards"><article class="metric"><span>Produtos</span><strong>{{ detail.total_products if detail.total_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Completos</span><strong>{{ detail.fiscally_complete if detail.fiscally_complete is not none else "Não informado" }}</strong></article><article class="metric"><span>Bloqueados com segurança</span><strong>{{ detail.safe_blocked if detail.safe_blocked is not none else "Não informado" }}</strong></article><article class="metric"><span>Falhas técnicas</span><strong>{{ detail.failed_products if detail.failed_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Não classificados</span><strong>{{ detail.unclassified_products if detail.unclassified_products is not none else "Não informado" }}</strong></article><article class="metric"><span>Cobertura fiscal</span>{% if detail.fiscal_completion_percent is none %}<strong>Não informado</strong><small>nenhum snapshot recebido</small>{% else %}<strong>{{ "%.1f"|format(detail.fiscal_completion_percent)|replace(".", ",") }}%</strong><small>{{ detail.fiscally_complete }} / {{ detail.total_products }} completos</small>{% endif %}</article></section>
{% if detail.total_products is none %}<p class="coverage-status">Cobertura ainda não informada: nenhum snapshot recebido deste servidor.</p>{% elif detail.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada para este servidor.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ detail.coverage_overcount_products }} acima do total{% if detail.coverage_invalid_counts %}; existem contagens negativas{% endif %}.</p>{% endif %}
<section class="panel ops-panel"><div class="panel-title"><div><h2>Processamento</h2><p class="muted">Controlado pelo Hub — nenhuma configuração local.</p></div><div class="ops-actions">
<form method="post" action="/agents/{{ detail.agent_id }}/sync-now" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" {% if not ops.enabled %}disabled title="Processamento pausado: retome para sincronizar"{% else %}title="Verifica apenas as alterações desde a última sincronização"{% endif %}>Sincronizar agora</button></form>
<a class="botao secondary" href="/agents/{{ detail.agent_id }}/reprocess-confirmation" title="Recalcula todos os produtos (pede confirmação)">Reprocessar tudo</a>
{% if ops.enabled %}<form method="post" action="/agents/{{ detail.agent_id }}/pause" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="caution" title="Mantém o heartbeat, mas não executa jobs">Pausar processamento</button></form>{% else %}<form method="post" action="/agents/{{ detail.agent_id }}/resume" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button type="submit" class="primary-action" title="Volta a executar jobs">Retomar processamento</button></form>{% endif %}
</div></div><dl class="facts"><dt>Processamento</dt><dd><span class="badge {{ '' if ops.enabled else 'critical' }}">{{ "Ativo" if ops.enabled else "Pausado" }}</span> · sincronização automática a cada {{ ops.interval_minutes }} min</dd><dt>Última sincronização</dt><dd>{{ ops.last_sync or "ainda não sincronizado" }}{% if ops.last_changed is not none %} · {{ ops.last_changed }} produto(s) alterado(s){% endif %}</dd><dt>Sincronização agendada</dt><dd>{{ ops.next_sync or ("aguardando o primeiro sync" if ops.enabled else "pausado") }}</dd>{% if ops.rtc %}<dt>Reforma Tributária</dt><dd><span class="badge {{ "" if ops.rtc.enabled else "muted" }}" title="{{ ops.rtc.title }}">{{ ops.rtc.label }}</span></dd>{% endif %}{% if ops.rtc and ops.rtc.enabled %}{% if ops.rtc.products and not ops.rtc.products.applicable %}<dt>cClassTrib</dt><dd>{{ ops.rtc.products.label }}</dd>{% elif ops.rtc.products %}<dt>Produtos RTC</dt><dd><b>{{ ops.rtc.products.headline }}</b><br><small>Total {{ ops.rtc.products.total }} · com cClassTrib válido {{ ops.rtc.products.valid }} · prontos para preenchimento {{ ops.rtc.products.ready }} · ambíguos/bloqueados {{ ops.rtc.products.blocked }}</small></dd>{% endif %}
<dt>Preenchimento automático (RTC, não é o Enriquecimento)</dt><dd>{{ ops.rtc.apply.state }}{% if ops.rtc.apply.applied_last_job %} · {{ ops.rtc.apply.applied_last_job }} preenchido(s) no último job{% endif %} · <a href="/agents/{{ detail.agent_id }}/rtc">Controle e candidatos cClassTrib</a></dd>{% endif %}<dt>Último job</dt><dd>{% if ops.last_job %}<a href="/jobs?scope=all&amp;job_id={{ ops.last_job.id }}">{{ ops.last_job.kind_label }}</a> · {{ ops.last_job.status_label }}{% else %}nenhum{% endif %}</dd></dl></section>
{% if is_admin %}<section class="panel" id="contexto-fiscal"><div class="panel-title"><div><h2>Contexto Fiscal</h2><p class="muted">Somente as decisões que o motor fiscal (field_contract) realmente não consegue provar sozinho no ERP. Visível apenas para administradores.</p></div></div>
<div style="margin-top:8px; padding:8px 12px; background:#fff3cd; color:#856404; border:1px solid #ffeeba; border-radius:4px; font-size:13px;"><strong>AVISO TÉCNICO (DADO_DE_TESTE=true):</strong> As decisões e contagens abaixo foram levantadas sobre base de homologação/laboratório (Agent 2 e bancos de teste). Trata-se de validação de mecanismo de motor, <em>não de achado fiscal sobre clientes reais</em>.</div>
{% for d in fiscal_context %}<article class="panel" style="margin-top:12px"><div class="panel-title"><h3>{{ d.title }}</h3><div><span class="badge" style="background:#fff3cd; color:#856404; border:1px solid #ffeeba;">DADO_DE_TESTE=true</span> <span class="badge">Escopo: {{ {"COMPANY":"Empresa","GROUP":"Grupo","PRODUCT":"Produto"}[d.scope] }}</span></div></div>
<dl class="facts"><dt>Valor atual</dt><dd>{{ d.current_value or "Não confirmado" }}{% if d.last_changed %} <small class="muted">· alterado em {{ d.last_changed }} por {{ d.last_changed_by }}</small>{% endif %}</dd>
<dt>Sugestão</dt><dd>{{ d.suggestion or "Sem sugestão automática — evidência insuficiente" }}</dd>
<dt>Evidência</dt><dd>{{ d.evidence }} <small class="muted">(medido manualmente em {{ d.measured_at }} — não é uma consulta ao vivo){% if d.dossier_link %} · <a href="{{ d.dossier_link }}" target="_blank" rel="noopener">dossiê para o contador</a>{% endif %}</small></dd>
<dt>Confiança</dt><dd>{{ d.confidence }}</dd>
<dt>Campos afetados</dt><dd>{{ d.affected_fields|join(", ") }}</dd></dl>
<form method="post" action="/agents/{{ detail.agent_id }}/fiscal-context" class="filters compact-filters"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="key" value="{{ d.key }}">
{% if d.allowed_values %}<label>Valor<select name="value">{% for v in d.allowed_values %}<option value="{{ v }}" {% if d.current_value==v %}selected{% endif %}>{{ v }}</option>{% endfor %}</select></label>{% else %}<label>Valor<input name="value" value="{{ d.current_value or '' }}" placeholder="{{ d.value_hint or 'ex.: {01,01,1.65,01,01,7.6} ou INTRA' }}"></label>{% endif %}
<label>Motivo<input name="reason" placeholder="opcional" autocomplete="off"></label>
<button class="secondary" type="submit" name="fc_action" value="confirm">Confirmar</button>
<button class="secondary" type="submit" name="fc_action" value="clear" onclick="return confirm('Limpar esta decisão? Os produtos voltam a depender só do que o ERP prova.')">Limpar</button></form>
</article>{% endfor %}
{% if is_admin %}<article class="panel" style="margin-top:12px"><div class="panel-title"><h3>Escrita CFOP/IPPT (Missão 4)</h3><span class="badge {{ '' if cfop_ippt_write_mode == 'DISABLED' else 'critical' }}">{{ cfop_ippt_write_mode }}</span></div>
<p class="muted">Capability CFOP_IPPT_WRITE_V1: escreve apenas C000025.CFOP e C000025.IPPT, só as células que o motor já classificou AUTO_FILL_SAFE. Desativada por padrão em toda instalação nova.</p>
<form method="post" action="/agents/{{ detail.agent_id }}/cfop-ippt-write" class="filters compact-filters"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label>Modo<select name="mode"><option value="DISABLED" {% if cfop_ippt_write_mode=='DISABLED' %}selected{% endif %}>Desativado</option><option value="DRY_RUN" {% if cfop_ippt_write_mode=='DRY_RUN' %}selected{% endif %}>Simulação (não grava)</option><option value="APPROVAL_REQUIRED" {% if cfop_ippt_write_mode=='APPROVAL_REQUIRED' %}selected{% endif %}>Requer aprovação</option><option value="ENABLED" {% if cfop_ippt_write_mode=='ENABLED' %}selected{% endif %}>Ativo</option></select></label>
<label>Para ativar (qualquer modo além de Desativado) digite GRAVAR<input name="confirm" autocomplete="off"></label>
<button class="secondary" type="submit">Salvar</button></form></article>{% endif %}</section>{% endif %}
<section class="panel" id="funcionalidades"><div class="panel-title"><div><h2>Funcionalidades (Enriquecimento de produtos novos)</h2><p class="muted">Decididas no Hub para esta instalação — o Agent não tem chave local. O Enriquecimento de produtos novos NÃO é o preenchimento RTC, que tem controle próprio (<a href="/agents/{{ detail.agent_id }}/rtc">controle e candidatos</a>).</p></div></div>
{% if features.waiting %}<p class="coverage-status error">Aguardando configuração de funcionalidades: esta instalação ainda não processa nada.</p>{% endif %}
<dl class="facts"><dt>Corretor fiscal</dt><dd><span class="badge {{ '' if features.corrector_enabled else 'critical' }}">{{ "Ativo" if features.corrector_enabled else "Desativado" }}</span></dd><dt>Enriquecimento de produtos novos</dt><dd><span class="badge {{ '' if features.enrichment_mode != 'DISABLED' else 'critical' }}">{{ features.state_label }}</span></dd></dl>
{% if is_admin %}<form method="post" action="/agents/{{ detail.agent_id }}/features" class="filters compact-filters"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><label><input type="checkbox" name="corrector_enabled" value="1" {% if features.corrector_enabled or features.waiting %}checked{% endif %}> Corretor fiscal</label><label>Enriquecimento<select name="enrichment_mode">{% for value, label in [("DISABLED","Desativado"),("DRY_RUN","Simulação (não grava)"),("ENABLED","Automático")] %}<option value="{{ value }}" {% if features.enrichment_mode==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label><label>Para o modo automático digite PREENCHER<input name="confirm" autocomplete="off"></label><button class="secondary" type="submit">Salvar funcionalidades</button></form>{% else %}<p class="muted">Somente administradores alteram as funcionalidades.</p>{% endif %}</section>
<section class="panel" id="enriquecimento"><div class="panel-title"><div><h2>Enriquecimento</h2><p class="muted">Produtos novos cadastrados no SGV; o catálogo histórico nunca é enviado a fontes externas.</p></div></div>
<dl class="facts"><dt>Produtos acompanhados</dt><dd>{{ features.total }}</dd><dt>Campos preenchidos</dt><dd>{{ features.applied }}</dd>{% for status, n in features.counts.items() %}<dt>{{ status }}</dt><dd>{{ n }}</dd>{% endfor %}</dl>
<form method="get" class="filters compact-filters"><label>Consultar produto<input name="enrich_code" value="{{ enrich_code }}" placeholder="código do produto"></label><button class="secondary" type="submit">Ver origem dos campos</button></form>
<div class="enrich-list" style="margin-top:12px">{% if features.tracked %}<table class="data-table"><thead><tr><th>Produto</th><th>Estado</th><th>Motivo / candidatos</th><th>Última tentativa</th><th></th></tr></thead><tbody>
{% for r in features.tracked %}<tr><td><b>{{ r.code }}</b> {{ r.description }}<br><small class="muted">EAN: {{ r.gtin or "não informado" }} · detectado {{ r.detected.strftime("%d/%m %H:%M") if r.detected else "—" }}</small></td>
<td><em class="badge {{ '' if r.ui_status in ['ENRIQUECIDO','PRONTO_PARA_PREENCHER','SIMULADO','NADA_A_PREENCHER'] else 'critical' }}">{{ r.ui_status }}</em>{% if r.placeholder %}<br><small class="muted">NCM provisório {{ r.placeholder }}</small>{% endif %}</td>
<td><small>{{ r.label }}{% if r.kb_status %} · base: {{ r.kb_status }}{% endif %}{% if r.ncm_candidate %} · NCM candidato {{ r.ncm_candidate }}{% endif %}{% if r.cest_candidate %} · CEST {{ r.cest_candidate }}{% endif %}{% if r.ipi_candidate %} · IPI {{ r.ipi_candidate }}{% endif %}{% if r.applied %} · gravado: {{ r.applied.keys()|join(", ") }}{% endif %}</small></td>
<td><small>{{ r.last_attempt.strftime("%d/%m %H:%M:%S") if r.last_attempt else "—" }}{% if r.next_retry %}<br>próximo retry {{ r.next_retry.strftime("%d/%m %H:%M") }}{% endif %}{% if r.reevaluate %}<br>reavaliação pedida{% endif %}</small></td>
<td>{% if features.enrichment_mode != "DISABLED" and r.ui_status not in ["ENRIQUECIDO","HUMAN_OVERRIDE"] %}<form method="post" action="/agents/{{ detail.agent_id }}/enrichment/reevaluate"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="code" value="{{ r.code }}"><button class="secondary" type="submit">Reavaliar</button></form>{% endif %}</td></tr>{% endfor %}</tbody></table>
{% if features.enrichment_mode != "DISABLED" %}<form method="post" action="/agents/{{ detail.agent_id }}/enrichment/reevaluate"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="secondary" type="submit">Reavaliar todos os pendentes</button> <small class="muted">Só reavalia (base/cache primeiro); preço editado não é produto novo.</small></form>{% endif %}
{% else %}<p class="empty-state">Nenhum produto novo acompanhado ainda. Só entram produtos cadastrados depois da ativação.</p>{% endif %}</div>
{% if features.lookup %}{% if features.lookup.found %}<dl class="facts"><dt>Produto</dt><dd>{{ features.lookup.row.product_code }} · {{ features.lookup.row.description }}</dd><dt>GTIN</dt><dd>{{ features.lookup.row.gtin or "sem GTIN válido" }}</dd><dt>Estado</dt><dd>{{ features.lookup.row.status }}{% if features.lookup.row.decisions.state %} · {{ features.lookup.row.decisions.state }}{% endif %}</dd></dl>{% for a in features.lookup.audits %}<div class="list-row"><span><b>{{ a.field }}</b>: {{ a.old_value or "vazio" }} → {{ a.new_value or "—" }}<small>{{ a.outcome }} · regra {{ a.rule or "—" }} · base {{ a.kb_status or "—" }}{% if a.evidence %} · {{ a.evidence|join(", ") }}{% endif %}</small></span></div>{% endfor %}{% else %}<p class="empty-state">Produto sem registro de enriquecimento.</p>{% endif %}{% endif %}</section>
<nav class="tabs" aria-label="Seções da instalação">{% for name,label in [("overview","Resumo"),("fiscal","Fiscal"),("blockers","Blockers"),("jobs","Jobs"),("events","Eventos")] %}<a class="{% if tab==name %}active{% endif %}" href="?tab={{ name }}">{{ label }}</a>{% endfor %}</nav>
<section class="split"><article class="panel"><div class="panel-title"><h2>Contexto da empresa</h2></div><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "Não informado" }}</dd><dt>Nome de exibição</dt><dd>{{ detail.display_name or "Não informado" }}</dd><dt>Hostname</dt><dd>{{ detail.hostname or "Não informado" }}</dd><dt>Empresa</dt><dd>{{ detail.company_name or detail.company_code or "Não informado" }}</dd><dt>UF</dt><dd>{{ detail.company_uf or "Não informado" }}</dd><dt>CRT</dt><dd>{{ detail.company_crt or "Não informado" }}</dd><dt>Versão do agent</dt><dd>{{ detail.agent_version_display }}</dd><dt>Último heartbeat</dt><dd title="{{ detail.last_seen_at }}">{{ detail.last_seen_display }} · {{ detail.last_seen_age }}</dd><dt>Firebird</dt><dd>{{ detail.firebird_version or "Não informado" }} · ODS {{ detail.firebird_ods or "Não informado" }}</dd><dt>IDs técnicos</dt><dd>Agent {{ detail.technical_ids.agent_id }} · instalação {{ detail.technical_ids.installation_db_id }}</dd></dl></article>
<article class="panel"><div class="panel-title"><h2>Eventos recentes</h2><a href="#eventos">Ver eventos</a></div>{% for event in events[:8] %}<div class="event-row"><span class="event-time">{{ event.occurred_display }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.job_id %} · <a href="/jobs?job_id={{ event.job_id }}">Job {{ event.job_id }}</a>{% endif %}{% if event.product_code %} · <a href="/blockers?agent_id={{ detail.agent_id }}&amp;product_code={{ event.product_code|urlencode }}">Produto {{ event.product_code }}</a>{% endif %}</small></span><em class="severity {{ event.severity|lower }}">Severidade: {{ event.severity }}</em></div>{% else %}<p class="empty-state">Nenhum evento.</p>{% endfor %}</article></section>
<section class="panel" id="eventos"><div class="panel-title"><div><h2>Eventos operacionais</h2><p class="muted">Filtros aplicados no servidor.</p></div></div><form class="filters compact-filters" method="get"><input type="hidden" name="tab" value="events"><label>Severidade<select name="severity"><option value="">Todas</option>{% for value in ["INFO","WARNING","ERROR","CRITICAL"] %}<option value="{{ value }}" {% if severity==value %}selected{% endif %}>{{ value }}</option>{% endfor %}</select></label><label>Tipo de evento<input name="event_type" value="{{ event_type }}"></label><label>Job ID<input name="job_id" value="{{ job_id }}" inputmode="numeric"></label><label>Produto<input name="product_code" value="{{ product_code }}"></label><label>Desde<input type="date" name="since" value="{{ since }}"></label><label>Até<input type="date" name="until" value="{{ until }}"></label><button class="secondary" type="submit">Filtrar eventos</button></form><nav class="pagination" aria-label="Paginação de eventos">{% if event_page > 1 %}<a href="/agents/{{ detail.agent_id }}?event_page={{ event_page - 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ event_page }}</span>{% if has_more %}<a href="/agents/{{ detail.agent_id }}?event_page={{ event_page + 1 }}{% if event_query_base %}&{{ event_query_base }}{% endif %}">Próxima</a>{% endif %}</nav></section>
<section class="panel"><div class="panel-title"><h2>Blockers abertos</h2><a href="/blockers?agent_id={{ detail.agent_id }}">Ver blockers</a></div><div class="bar-grid">{% for key in ["CFOP","ST","ICMS","CSOSN","NCM","OTHER"] %}<div class="bar-row"><span>{{ key }}</span><b>{{ blocker_breakdown[key] }}</b></div>{% endfor %}</div>{% for item in blockers["items"][:8] %}<a class="list-row" href="{{ item.link }}"><span><b>{{ item.product_code }}</b> · {{ item.field }}<small>{{ item.reason_label }} · {{ item.age }}</small></span><em class="badge blocked">{{ item.blocker_type_label }}</em></a>{% else %}<p class="empty-state">Nenhum blocker aberto.</p>{% endfor %}</section>
{% endblock %}
@@ -0,0 +1,119 @@
{% extends "base.html" %}
{% block title %}Nova instalação | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>Nova instalação</h1><p class="muted">Cria uma instalação pendente; o pacote e o vínculo de enrollment só existem depois de gerados aqui.</p></div><div><a href="/agents" class="secondary">Voltar</a></div></div>
<section class="panel" style="max-width:560px">
{% if error %}<p class="error" role="alert">{{ error }}</p>{% endif %}
{% if not releases %}
<p class="muted">Nenhuma versão do Agent está publicada ainda. Publique uma release primeiro (<code>python central/scripts/publish_agent_release.py</code>) e ela aparecerá aqui.</p>
{% elif not tenants %}
<p class="muted">Nenhum cliente ativo encontrado no GTI Gerenciador. Verifique o cadastro por lá, ou rode a sincronização (<code>python central/scripts/sync_clients_from_gerenciador.py</code>).</p>
{% else %}
<form method="post" action="/agents/new">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="combo-field">
<label for="cliente-busca">Cliente</label>
<div class="combo" id="combo-cliente">
<input type="text" id="cliente-busca" placeholder="Buscar por nome, código ou CNPJ..." autocomplete="off" role="combobox" aria-expanded="false" aria-controls="cliente-lista" aria-autocomplete="list">
<input type="hidden" name="client_id" id="cliente-id" value="">
<ul id="cliente-lista" class="combo-lista" role="listbox" hidden></ul>
</div>
<p class="error combo-erro" id="cliente-erro" role="alert" hidden>Selecione um cliente da lista.</p>
</div>
<script type="application/json" id="clientes-json">{{ clients_json|safe }}</script>
<label>Nome da instalação
<input name="display_name" placeholder="ex. Servidor Principal" maxlength="160" required>
</label>
<label>Hostname esperado (opcional)
<input name="expected_hostname" placeholder="ex. SRV-CONSTRUCAO" maxlength="255">
</label>
<label>Versão do Agent
<select name="release_id" required>
{% for release in releases %}<option value="{{ release.id }}" {% if loop.first %}selected{% endif %}>{{ release.version }} ({{ release.platform }}/{{ release.architecture }})</option>{% endfor %}
</select>
</label>
<fieldset class="features">
<legend>Funcionalidades desta instalação</legend>
<input type="hidden" name="features_selected" value="1">
<label><input type="checkbox" name="feature_corrector" value="1" checked> Corretor fiscal (sincroniza e corrige o cadastro existente)</label>
<label>Enriquecimento de produtos novos
<select name="feature_enrichment_mode">
<option value="DISABLED" selected>Desativado</option>
<option value="DRY_RUN">Simulação (não grava)</option>
<option value="ENABLED">Automático</option>
</select>
</label>
<small class="muted">Nada é processado até as funcionalidades estarem definidas. Pode ser alterado depois, no detalhe do Agent.</small>
</fieldset>
<p><button type="submit">Criar instalação</button></p>
</form>
<style>
.combo { position: relative; }
.combo-lista { position: absolute; z-index: 20; left: 0; right: 0; top: 100%; margin: 4px 0 0; padding: 4px; list-style: none; max-height: 280px; overflow-y: auto; background: var(--cor-superficie); border: 1px solid var(--cor-borda); border-radius: 8px; box-shadow: var(--sombra-card); }
.combo-lista li { padding: 8px 10px; border-radius: 6px; cursor: pointer; display: flex; flex-direction: column; gap: 2px; }
.combo-lista li small { color: var(--cor-texto-secundario); }
.combo-lista li:hover, .combo-lista li.ativo { background: var(--cor-superficie-alt); }
.combo-lista li.selecionado { background: var(--cor-acento-suave); color: var(--cor-acento-texto); font-weight: 600; }
.combo-lista li.vazio { cursor: default; color: var(--cor-texto-secundario); }
.combo.invalido input[type=text] { border-color: var(--cor-perigo); }
.combo-field { margin-bottom: 12px; }
</style>
<script>
(function () {
var clientes = JSON.parse(document.getElementById("clientes-json").textContent);
var box = document.getElementById("cliente-busca"), hid = document.getElementById("cliente-id");
var lista = document.getElementById("cliente-lista"), erro = document.getElementById("cliente-erro");
var combo = document.getElementById("combo-cliente"), form = box.form;
var visiveis = [], ativo = -1;
function norm(t) { return (t || "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase(); }
function cnpjFmt(d) {
if (!d) return "";
if (d.length === 14) return d.replace(/^(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})$/, "$1.$2.$3/$4-$5");
if (d.length === 11) return d.replace(/^(\d{3})(\d{3})(\d{3})(\d{2})$/, "$1.$2.$3-$4");
return d;
}
function abrir(v) { lista.hidden = !v; box.setAttribute("aria-expanded", v ? "true" : "false"); }
function render() {
var q = norm(box.value), qd = q.replace(/\D/g, "");
visiveis = clientes.filter(function (c) {
if (!q) return true;
return norm(c.name).indexOf(q) !== -1 || norm(c.code).indexOf(q) !== -1 || (qd && (c.cnpj || "").indexOf(qd) !== -1);
}).slice(0, 100);
lista.innerHTML = "";
if (!visiveis.length) {
var vazio = document.createElement("li"); vazio.className = "vazio"; vazio.textContent = "Nenhum cliente ativo encontrado."; lista.appendChild(vazio);
}
visiveis.forEach(function (c, i) {
var li = document.createElement("li"); li.setAttribute("role", "option"); li.dataset.i = i;
if (String(c.id) === hid.value) li.classList.add("selecionado");
var nome = document.createElement("span"); nome.textContent = c.name; li.appendChild(nome);
if (c.cnpj) { var sm = document.createElement("small"); sm.textContent = cnpjFmt(c.cnpj); li.appendChild(sm); }
li.addEventListener("mousedown", function (e) { e.preventDefault(); escolher(c); });
lista.appendChild(li);
});
ativo = -1; abrir(true);
}
function destacar(n) {
var itens = lista.querySelectorAll("li[role=option]"); if (!itens.length) return;
ativo = (n + itens.length) % itens.length;
itens.forEach(function (li, i) { li.classList.toggle("ativo", i === ativo); });
itens[ativo].scrollIntoView({ block: "nearest" });
}
function escolher(c) { box.value = c.name; hid.value = c.id; erro.hidden = true; combo.classList.remove("invalido"); abrir(false); }
box.addEventListener("input", function () { hid.value = ""; render(); });
box.addEventListener("focus", render);
box.addEventListener("blur", function () { setTimeout(function () { abrir(false); }, 120); });
box.addEventListener("keydown", function (e) {
if (e.key === "ArrowDown") { e.preventDefault(); if (lista.hidden) render(); destacar(ativo + 1); }
else if (e.key === "ArrowUp") { e.preventDefault(); destacar(ativo - 1); }
else if (e.key === "Enter") { if (!lista.hidden && ativo >= 0 && visiveis[ativo]) { e.preventDefault(); escolher(visiveis[ativo]); } else if (!hid.value) { e.preventDefault(); } }
else if (e.key === "Escape") { abrir(false); }
});
form.addEventListener("submit", function (e) {
if (!hid.value) { e.preventDefault(); erro.hidden = false; combo.classList.add("invalido"); box.focus(); }
});
})();
</script>
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,75 @@
{% extends "base.html" %}
{% block title %}{{ enr.display_name }} | Instalação | GTI Fiscal{% endblock %}
{% block content %}
<div class="detail-head"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>{{ enr.display_name }}</h1><p class="muted">{{ tenant.name if tenant else "Cliente não encontrado" }} · release {{ release.version if release else "?" }}</p></div><div><span class="badge">{{ enr.status }}</span></div></div>
{% if download_link %}
<section class="panel">
<h2>Link temporário gerado</h2>
<p class="muted">Mostrado uma única vez, uso único, expira em pouco tempo. Copie agora — ele não pode ser recuperado depois.</p>
<p><code style="word-break:break-all">{{ download_link }}</code></p>
</section>
{% endif %}
{% if install_code %}
<section class="panel">
<h2>Código de instalação gerado</h2>
<p class="muted">Mostrado uma única vez. Informe este código ao operador no momento da instalação do instalador (.EXE) no Windows. Expira automaticamente.</p>
<p><code style="font-size:1.4rem;letter-spacing:2px">{{ install_code }}</code>
<button type="button" onclick="navigator.clipboard.writeText('{{ install_code }}');this.textContent='Copiado ✓';setTimeout(()=>this.textContent='Copiar',1500)">Copiar</button></p>
</section>
{% endif %}
{% if code_active %}
<section class="panel">
<h2>Código de instalação ativo</h2>
<p class="muted">Inserir no instalador (.EXE) quando a instalação for feita. Expira em <strong>{{ code_expires_at }}</strong>.</p>
<p class="muted">Gerar um novo código invalida o atual.</p>
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/code" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Gerar novo código</button>
</form>
</section>
{% endif %}
<section class="panel">
<h2>Detalhes</h2>
<table>
<tr><th>Cliente</th><td>{{ tenant.name if tenant else "-" }}{% if tenant and not tenant.active %} <span class="badge critical">INATIVO</span>{% endif %}</td></tr>
<tr><th>Nome</th><td>{{ enr.display_name }}</td></tr>
<tr><th>Hostname esperado</th><td>{{ enr.expected_hostname or "não informado" }}</td></tr>
<tr><th>Versão do Agent</th><td>{{ release.version if release else "-" }} ({{ release.platform if release else "" }}/{{ release.architecture if release else "" }})</td></tr>
<tr><th>Status</th><td><span class="badge">{{ enr.status }}</span>{% if enr.hostname_mismatch %} <span class="badge critical">hostname divergente</span>{% endif %}</td></tr>
<tr><th>Criada em</th><td>{{ enr.created_at }}</td></tr>
<tr><th>Baixada em</th><td>{{ enr.downloaded_at or "ainda não" }}</td></tr>
<tr><th>Hostname visto</th><td>{{ enr.hostname_seen or "-" }}</td></tr>
{% if installed_agent %}
<tr><th>Agent</th><td><a href="/agents/{{ installed_agent.id }}">#{{ installed_agent.id }}</a> · {{ "ativo" if installed_agent.enabled else "desativado" }} · último heartbeat: {{ installed_agent.last_seen_at or "nenhum ainda" }}</td></tr>
{% endif %}
</table>
</section>
<section class="panel">
<h2>Ações</h2>
{% if enr.status not in ("ENROLLED", "REVOKED") %}
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/code" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">{{ "Gerar novo código" if code_active else "Gerar código de instalação" }}</button>
</form>
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/link" style="display:inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Gerar link temporário</button>
</form>
<a href="/agents/pending/{{ enr.enrollment_uuid }}/download" class="botao">Baixar Agent ({{ release.filename if release else "instalador" }})</a>
{% endif %}
{% if enr.status != "REVOKED" %}
<form method="post" action="/agents/pending/{{ enr.enrollment_uuid }}/revoke" style="display:inline" onsubmit="return confirm('Revogar esta instalação? Links, o código e o Agent (se já ativo) deixam de funcionar.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Revogar</button>
</form>
{% endif %}
{% if enr.status == "ENROLLED" and installed_agent %}
<a href="/agents/{{ installed_agent.id }}" class="secondary">Ver Agent</a>
{% endif %}
</section>
{% endblock %}
@@ -0,0 +1,35 @@
{% extends "base.html" %}
{% block title %}Releases do Agent | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">DISTRIBUIÇÃO DO AGENT</p><h1>Versões do Agent</h1><p class="muted">O binário é universal por versão; publicação é feita por script (<code>central/scripts/publish_agent_release.py</code>), nunca por upload no navegador.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents" class="secondary">Voltar</a></div></div>
<div class="panel table-wrap">
<table>
<caption>Releases catalogadas</caption>
<thead><tr><th>Versão</th><th>Plataforma</th><th>Status</th><th>SHA-256</th><th>Tamanho</th><th>Publicada em</th><th>Instalações</th><th></th></tr></thead>
<tbody>
{% for release in releases %}
<tr>
<td><b>{{ release.version }}</b></td>
<td>{{ release.platform }}/{{ release.architecture }} {% if release.filename.lower().endswith(".exe") %}<span class="badge">EXE</span>{% endif %}</td>
<td><span class="badge {{ 'critical' if release.status == 'REVOKED' else '' }}">{{ release.status }}</span></td>
<td><code>{{ release.sha256[:12] }}…</code></td>
<td>{{ "%.1f"|format(release.size_bytes / 1048576) }} MB</td>
<td>{{ release.published_at or "não publicada" }}</td>
<td>{{ installation_counts.get(release.id, 0) }}</td>
<td>
{% if release.status == "PUBLISHED" %}
<span class="muted" style="font-size:.8rem">{{ release.filename }}</span>
<form method="post" action="/agents/releases/{{ release.id }}/revoke" onsubmit="return confirm('Revogar a release {{ release.version }}? Nenhum novo download poderá usá-la.');">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="secondary">Revogar</button>
</form>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="8" class="empty-state">Nenhuma release publicada ainda.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+31
View File
@@ -0,0 +1,31 @@
{% extends "base.html" %}
{% block title %}Agents / Servidores | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">INVENTÁRIO OPERACIONAL</p><h1>Agents / Servidores</h1><p class="muted">Clientes e servidores ativos. Use Histórico para consultar instalações inativas ou revogadas.</p></div><div><a href="/agents/new" class="botao">+ Nova instalação</a> <a href="/agents/releases" class="secondary">Releases</a></div></div>
<section class="panel filter-panel">
<form class="filters agents-filters" method="get">
{% if version_filter %}<input type="hidden" name="version" value="{{ version_filter }}">{% endif %}
<label class="filter-search">Cliente, instalação ou hostname<input name="search" type="search" value="{{ search }}" placeholder="Buscar cliente ou servidor"></label>
<label>Saúde técnica<select name="health"><option value="">Todos</option>{% for value,label in [("ONLINE","Online"),("DEGRADED","Atenção"),("OFFLINE","Offline"),("FAILED","Falhou"),("UNKNOWN","Não informado")] %}<option value="{{ value }}" {% if health_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
<label>Ordenação<select name="sort"><option value="display_name" {% if sort=="display_name" %}selected{% endif %}>Cliente / instalação</option><option value="worst_coverage" {% if sort=="worst_coverage" %}selected{% endif %}>Pior cobertura fiscal</option></select></label>
<button class="secondary" type="submit">Filtrar</button>
{% if search or health_filter or version_filter or scope_filter=="all" or sort!="display_name" %}<a class="secondary filter-clear" href="/agents?scope=active">Limpar filtros</a>{% endif %}
</form>
{% if health_filter or version_filter or scope_filter=="all" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if health_filter %}<span class="filter-chip">Saúde: {{ {"ONLINE":"Online","DEGRADED":"Atenção","OFFLINE":"Offline","FAILED":"Falhou","UNKNOWN":"Não informado"}[health_filter] }}</span>{% endif %}{% if version_filter %}<span class="filter-chip">Agents desatualizados</span>{% endif %}</p>{% endif %}
</section>
<div class="panel table-wrap agents-table-wrap"><table class="agents-table"><caption>Instalações monitoradas · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Saúde</th><th>Status fiscal</th><th>Cobertura</th><th>Produtos</th><th>Versão</th><th>Último contato</th></tr></thead><tbody>
{% for item in result["items"] %}
<tr class="navigable-row{% if not item.operational_active %} historical-row{% endif %}" data-href="/agents/{{ item.agent_id }}">
<td data-label="Cliente / servidor"><a class="row-primary-link" href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Servidor não informado" }}</small></a>{% if item.display_name and item.hostname and item.display_name!=item.hostname %}<small>{{ item.display_name }}</small>{% endif %}{% if not item.operational_active %}<span class="badge unknown">Histórico / inativo</span>{% endif %}</td>
<td data-label="Saúde"><span class="badge {{ item.technical_health|lower }}">{{ item.technical_health_label }}</span></td>
<td data-label="Status fiscal"><span class="badge fiscal-{{ item.fiscal_status|lower }}">{{ item.fiscal_status_label }}</span>{% if item.total_products is not none %}<small>{{ item.fiscally_complete }} completos · {{ item.safe_blocked }} bloqueados{% if item.failed_products %} · {{ item.failed_products }} falhas{% endif %}</small>{% endif %}{% if not item.coverage_invariant_pass %}<small class="error">Inconsistência no snapshot</small>{% endif %}</td>
<td data-label="Cobertura">{% if item.fiscal_completion_percent is none %}Não informada{% else %}<strong>{{ "%.1f"|format(item.fiscal_completion_percent)|replace(".", ",") }}%</strong>{% endif %}</td>
<td data-label="Produtos">{{ item.total_products if item.total_products is not none else "Não informado" }}</td>
<td data-label="Versão">{{ item.agent_version or "Não informada" }}{% if item.version_drift=="OUTDATED" %}<small class="warning-text">Atualização disponível</small>{% endif %}</td>
<td data-label="Último contato" title="{{ item.last_seen_at }}">{{ item.last_seen_age }}<small>{{ item.last_seen_display }}</small></td>
</tr>
{% else %}<tr><td colspan="7" class="empty-state">Nenhuma instalação encontrada para os filtros atuais. <a href="/agents?scope=all">Consultar histórico</a></td></tr>{% endfor %}
</tbody></table></div>
<nav class="pagination" aria-label="Paginação de agents">{% if result.page > 1 %}<a href="/agents?page={{ result.page - 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} instalações</span>{% if result.page * result.page_size < result.total %}<a href="/agents?page={{ result.page + 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
{% endblock %}
+90
View File
@@ -0,0 +1,90 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% block title %}GTI Fiscal{% endblock %}</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<!-- Mesma identidade visual do GTI Gerenciador: tokens/layout em gti-theme.css (cópia fiel), ajustes do Fiscal em control-panel.css -->
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
<script>
(function () {
var tema = localStorage.getItem("tema");
// Sem preferência salva o padrão é o tema escuro; a escolha explícita do usuário sempre prevalece.
document.documentElement.setAttribute("data-tema", tema === "claro" ? "claro" : "escuro");
})();
</script>
</head>
<body>
{% set path = request.url.path %}
<div class="layout">
<div class="sidebar-backdrop" id="sidebar-backdrop"></div>
<aside class="sidebar" id="sidebar">
<div class="sidebar-topo">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="sidebar-logo">
<span class="sidebar-titulo">GTI Fiscal</span>
</div>
<nav class="sidebar-nav" aria-label="Navegação principal">
{% if panel_home %}
<a href="{{ panel_home }}" class="sidebar-voltar">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M15 18l-6-6 6-6"/></svg>
<span>Painel de Clientes</span>
</a>
<div class="sidebar-secao"><span>Fiscal</span></div>
{% endif %}
<a href="/" class="{{ 'ativo' if path == '/' else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>
<span>Resumo</span>
</a>
<a href="/agents" class="{{ 'ativo' if path.startswith('/agents') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="7" rx="2"/><rect x="3" y="13" width="18" height="7" rx="2"/><path d="M7 7.5h.01M7 16.5h.01"/></svg>
<span>Agents / Servidores</span>
</a>
<a href="/blockers" class="{{ 'ativo' if path.startswith('/blockers') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 3l8 4v5c0 5-3.5 8-8 9-4.5-1-8-4-8-9V7l8-4Z"/><path d="M12 8v5M12 16h.01"/></svg>
<span>Blockers</span>
</a>
<a href="/jobs" class="{{ 'ativo' if path.startswith('/jobs') else '' }}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01"/></svg>
<span>Jobs</span>
</a>
</nav>
<div class="sidebar-rodape">
{% if auth %}
<div class="sidebar-usuario">{{ auth[1].display_name or auth[1].username }}</div>
{% endif %}
<button type="button" class="botao-tema" id="botao-tema" aria-label="Alternar tema claro/escuro">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41"/></svg>
<span>Tema claro/escuro</span>
</button>
{% if auth %}
<form method="post" action="/logout" class="form-logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" class="botao-sair">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><path d="M16 17l5-5-5-5M21 12H9"/></svg>
<span>Sair do Fiscal</span>
</button>
</form>
{% endif %}
<button type="button" class="botao-recolher" id="botao-recolher" aria-label="Recolher menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 19l-7-7 7-7M18 19l-7-7 7-7"/></svg>
<span>Recolher</span>
</button>
</div>
</aside>
<div class="conteudo-area">
<header class="topbar-mobile">
<button type="button" class="topbar-menu-btn" id="btn-menu-mobile" aria-label="Abrir menu">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="width:22px;height:22px"><path d="M3 6h18M3 12h18M3 18h18"/></svg>
</button>
<span class="topbar-titulo">GTI Fiscal</span>
</header>
<main class="conteudo conteudo-fiscal">{% block content %}{% endblock %}</main>
</div>
</div>
<script src="/static/sidebar.js" defer></script>
<script src="/static/tema.js" defer></script>
<script src="/static/control-panel.js"></script>
</body>
</html>
@@ -0,0 +1,16 @@
{% extends "base.html" %}
{% block title %}Blocker {{ detail.id }} | GTI Fiscal{% endblock %}
{% block content %}
<div class="detail-head"><div><p class="eyebrow">RESOLUÇÃO OPERACIONAL</p><h1>Blocker {{ detail.id }}</h1><p class="muted">{{ detail.client or "Cliente não identificado" }} · {{ detail.product_code }}</p></div><div class="status-stack"><div class="status-block"><small class="status-label">Tipo</small><span class="badge blocked large">{{ detail.blocker_type_label }}</span></div><div class="status-block"><small class="status-label">Prioridade</small><span class="badge priority-{{ detail.priority|lower }} large">{{ detail.priority_label }}</span></div></div></div>
{% if error %}<div class="panel"><p class="error">{{ error }}</p></div>{% endif %}
<section class="split">
<article class="panel"><h2>Detalhes do blocker</h2><dl class="facts"><dt>Cliente</dt><dd>{{ detail.client or "-" }}</dd><dt>Instalação</dt><dd>{{ detail.installation.display_name or "-" }}</dd><dt>Servidor</dt><dd>{{ detail.server.hostname or "-" }}</dd><dt>Produto</dt><dd>{{ detail.product_code }}</dd><dt>Descrição</dt><dd>{{ detail.description or "-" }}</dd><dt>Campo</dt><dd>{{ detail.field or "OTHER" }}</dd><dt>Tipo</dt><dd>{{ detail.blocker_type_label }}</dd><dt>Motivo</dt><dd>{{ detail.reason_label }}</dd><dt>Idade</dt><dd>{{ detail.age }}</dd><dt>Primeira ocorrência</dt><dd title="{{ detail.first_seen_at }}">{{ detail.first_seen_display }}</dd><dt>Última ocorrência</dt><dd title="{{ detail.last_seen_at }}">{{ detail.last_seen_display }}</dd><dt>Evidência</dt><dd><pre class="evidence">{{ detail.evidence|tojson(indent=2) }}</pre></dd></dl></article>
<article class="panel"><h2>Contexto atual</h2><p class="muted">Valor atual por chave persistida para esta empresa.</p>{% for entry in context_entries %}<div class="list-row"><span><b>{{ entry.key }}</b><small>{{ entry.scope }} / {{ entry.scope_key or "empresa" }}</small></span><em>{{ entry.value }}</em></div>{% else %}<p class="muted">Nenhum valor definido.</p>{% endfor %}</article>
</section>
{% if resolution_available %}
<section class="panel"><h2>Salvar contexto e reprocessar</h2><p class="muted">Escolha uma chave fiscal validada. O reprocessamento será limitado ao produto deste blocker.</p><form method="post" action="/blockers/{{ detail.id }}/resolve-context"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><div class="form-grid"><label for="context-key">Chave de contexto<select id="context-key" name="key" required>{% for key in context_keys %}<option value="{{ key }}"{% if resolution_values.get('key') == key %} selected{% endif %}>{{ key }}</option>{% endfor %}</select></label><label for="context-value">Valor<input id="context-value" name="value" required maxlength="512" value="{{ resolution_values.get('value', '') }}"><small>Valores aceitos: {{ value_choices|join(", ") }}</small></label><label for="context-scope">Escopo<select id="context-scope" name="scope" required>{% for scope in ('PRODUCT', 'GROUP', 'COMPANY') %}<option value="{{ scope }}"{% if resolution_values.get('scope', 'PRODUCT') == scope %} selected{% endif %}>{{ scope }}</option>{% endfor %}</select></label><label for="scope-key">Chave do escopo<input id="scope-key" name="scope_key" value="{{ resolution_values.get('scope_key', detail.product_code) }}" required></label><label class="wide" for="context-reason">Nota<textarea id="context-reason" name="reason" required maxlength="2000">{{ resolution_values.get('reason', '') }}</textarea></label></div><div class="panel-title"><a href="/blockers">Cancelar</a><button type="submit">Salvar contexto e reprocessar</button></div></form></section>
{% else %}
<section class="panel"><h2>Resolução de contexto indisponível</h2><p class="error">Este é um blocker legado de empresa, sem vínculo comprovado com um servidor. Não é possível oferecer o reprocessamento de contexto com segurança.</p></section>
{% endif %}
<section class="split"><article class="panel"><h2>Eventos relacionados</h2>{% for event in detail.related_events[:10] %}<div class="event-row"><span>{{ event.severity }}</span><span><b>{{ event.event_type }}</b><small>{{ event.message }}{% if event.product_code %} · Produto {{ event.product_code }}{% endif %}</small></span></div>{% else %}<p class="empty-state">Nenhum evento relacionado.</p>{% endfor %}</article><article class="panel"><h2>Jobs relacionados</h2>{% for job in detail.related_jobs[:10] %}<div class="list-row"><span><b>Job {{ job.job_id }}</b><small>{{ job.kind }} · {{ job.status }}</small></span><em>{{ job.duration }}</em></div>{% else %}<p class="empty-state">Nenhum job relacionado.</p>{% endfor %}</article></section>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Blockers | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">FILA FISCAL</p><h1>Blockers</h1><p class="muted">Problemas fiscais da operação ativa; histórico e resolvidos disponíveis no filtro.</p></div></div>
<section class="cards compact" aria-label="Indicadores de blockers">
<a class="metric metric-link" href="/blockers?scope={{ scope_filter }}&amp;status=OPEN"><span>Blockers abertos</span><strong>{{ summary.open_blockers }}</strong><small>Ver abertos →</small></a>
<div class="metric"><span>Produtos afetados</span><strong>{{ summary.affected_products }}</strong></div>
<div class="metric"><span>Clientes afetados</span><strong>{{ summary.affected_clients }}</strong></div>
<div class="metric{% if field_filter or client_filter or installation_filter or blocker_type_filter or reason_code_filter or search or age_filter or scope_filter=='all' or status_filter!='OPEN' %} metric-selected{% endif %}"><span>Resultado filtrado</span><strong>{{ result.total }}</strong></div>
</section>
<section class="panel filter-panel">
<form class="filters blocker-filters" method="get">
{% if request.query_params.get('agent_id') %}<input type="hidden" name="agent_id" value="{{ request.query_params.get('agent_id') }}">{% endif %}
{% if request.query_params.get('product_code') %}<input type="hidden" name="product_code" value="{{ request.query_params.get('product_code') }}">{% endif %}
{% if reason_code_filter %}<input type="hidden" name="reason_code" value="{{ reason_code_filter }}">{% endif %}
<label>Cliente<input name="client" value="{{ client_filter }}"></label>
<label>Instalação / servidor<input name="installation" value="{{ installation_filter }}"></label>
<label>Campo<select name="field"><option value="">Todos</option>{% for value in ["NCM","CEST","CFOP","ST","ICMS","CSOSN","PIS","COFINS","RTC","OTHER"] %}<option value="{{ value }}" {% if field_filter==value %}selected{% endif %}>{{ value if value!="OTHER" else "Outros" }}</option>{% endfor %}</select></label>
<label>Tipo de blocker<input name="blocker_type" value="{{ blocker_type_filter }}" placeholder="Tipo técnico ou nome"></label>
<label>Status<select name="status"><option value="OPEN" {% if status_filter=="OPEN" %}selected{% endif %}>Abertos</option><option value="RESOLVED" {% if status_filter=="RESOLVED" %}selected{% endif %}>Resolvidos</option><option value="ALL" {% if status_filter=="ALL" %}selected{% endif %}>Todos</option></select></label>
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
<label>Idade<input name="age" value="{{ age_filter }}" placeholder="ex.: 1d"></label>
<label class="filter-search">Produto ou busca<input name="search" value="{{ search }}" type="search"></label>
<button class="secondary" type="submit">Filtrar blockers</button>
{% if field_filter or client_filter or installation_filter or blocker_type_filter or reason_code_filter or search or age_filter or scope_filter=="all" or status_filter!="OPEN" or request.query_params.get('agent_id') or request.query_params.get('product_code') %}<a class="secondary filter-clear" href="/blockers?scope=active">Limpar filtros</a>{% endif %}
</form>
{% if field_filter or reason_code_filter or scope_filter=="all" or status_filter!="OPEN" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if field_filter %}<span class="filter-chip">Campo: {{ field_filter }}</span>{% endif %}{% if reason_code_filter %}<span class="filter-chip">Motivo: {{ reason_code_filter }}</span>{% endif %}{% if status_filter!="OPEN" %}<span class="filter-chip">Status: {{ "Resolvidos" if status_filter=="RESOLVED" else "Todos" }}</span>{% endif %}</p>{% endif %}
</section>
<section class="panel"><div class="panel-title"><h2>Distribuição dos blockers filtrados</h2></div><div class="bar-grid">{% for key in ["NCM","CEST","CFOP","ST","ICMS","CSOSN","PIS","COFINS","RTC","OTHER"] %}{% if summary.blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?{{ field_query_base|e }}&amp;field={{ key }}"><span>{{ key }}</span><b>{{ summary.blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}{% endfor %}</div></section>
<div class="panel table-wrap blockers-table-wrap"><table class="blockers-table"><caption>Fila de blockers · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Produto</th><th>Campo / tipo</th><th>Prioridade</th><th>Problema</th><th>Última ocorrência</th><th>Ação</th></tr></thead><tbody>
{% for item in result["items"] %}<tr><td data-label="Cliente / servidor"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Instalação não informada" }}</small></td><td data-label="Produto"><a class="row-primary-link" href="{{ item.link }}"><b>{{ item.product_code }}</b><small>{{ item.description or "Sem descrição" }}</small></a></td><td data-label="Campo / tipo">{{ item.field or "Outros" }}<small>{{ item.blocker_type_label }}</small></td><td data-label="Prioridade"><span class="badge priority-{{ item.priority|lower }}">{{ item.priority_label }}</span></td><td data-label="Problema">{{ item.reason_label }}<small>{{ item.age }}</small></td><td data-label="Última ocorrência" title="{{ item.last_seen_at }}">{{ item.last_seen_display }}<small>{{ item.retry_count }} tentativas</small></td><td data-label="Ação"><a class="secondary" href="{{ item.link }}">Ver detalhes</a></td></tr>{% else %}<tr><td colspan="7" class="empty-state">Nenhum blocker corresponde aos filtros. <a href="/blockers?scope=all&amp;status=ALL">Consultar histórico</a></td></tr>{% endfor %}
</tbody></table></div>
<nav class="pagination" aria-label="Paginação de blockers">{% if result.page > 1 %}<a href="/blockers?page={{ result.page - 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} blockers</span>{% if result.page * result.page_size < result.total %}<a href="/blockers?page={{ result.page + 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
{% endblock %}
+80
View File
@@ -0,0 +1,80 @@
{% extends "base.html" %}
{% block title %}Resumo | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading">
<div><p class="eyebrow">OPERAÇÃO CENTRAL</p><h1>Qual cliente precisa de atenção?</h1><p class="muted">Somente instalações ativas. Histórico permanece disponível nas listagens.</p></div>
<span class="live-dot">Atualizado pelo snapshot</span>
</div>
<section class="cards" aria-label="Saúde dos servidores">
{% for key,label,health in [("agents_total","Servidores",none),("online","Online","ONLINE"),("degraded","Atenção","DEGRADED"),("offline","Offline","OFFLINE"),("failed","Falhou","FAILED"),("unknown","Não informado","UNKNOWN")] %}
<a class="metric metric-link" href="/agents?scope=active{% if health %}&amp;health={{ health }}{% endif %}" aria-label="{{ label }}: {{ summary[key] }}. Ver servidores{% if health %} filtrados{% endif %}">
<span>{{ label }}</span><strong>{{ summary[key] }}</strong><small>Ver servidores <span aria-hidden="true">→</span></small>
</a>
{% endfor %}
</section>
<section class="split overview-split" id="fontes">
<article class="panel">
<div class="panel-title"><div><h2>Fontes de produtos</h2><p class="muted">Consultas feitas somente pelo Hub, dentro do limite gratuito.</p></div></div>
{% for src in sources %}<div class="list-row"><span><b>{{ src.provider }}</b> <em class="badge {{ '' if src.status == 'ONLINE' else 'critical' }}">{{ src.status_label }}</em>
<small>{% if src.limit %}{{ src.used }} / {{ src.limit }} consultas hoje · restam {{ src.remaining if src.remaining is not none else "—" }}{% if src.reset_at %} · renova {{ src.reset_at.strftime("%d/%m %H:%M") }} UTC{% endif %}{% elif src.rate_per_minute %}{{ src.rate_per_minute }} consultas/min (limite documentado) · {{ src.used }} hoje{% else %}{{ src.used }} consultas hoje{% endif %}
{% if src.latency_ms %} · {{ src.latency_ms }} ms{% endif %}{% if src.cache_hit_rate is not none %} · cache {{ src.cache_hit_rate }}%{% endif %}
{% if src.last_success_at %} · último sucesso {{ src.last_success_at.strftime("%d/%m %H:%M") }}{% endif %}{% if src.last_error %} · último erro: {{ src.last_error }}{% endif %}
· hoje: {{ src.found_today }} achados{% if not src.identity_only %}, {{ src.ncm_found_today }} com NCM{% else %} (só identidade){% endif %}, {{ src.cache_hits }} do cache, {{ src.rate_limited }} limitados{% if src.next_reset %} · próximo reset {{ src.next_reset.strftime("%d/%m %H:%M") }} UTC{% endif %}
· configurado: {{ "sim" if src.configured else "não" }}</small></span></div>{% endfor %}
</article>
<article class="panel">
<div class="panel-title"><div><h2>Fontes oficiais</h2><p class="muted">Bases fiscais usadas para validar qualquer preenchimento.</p></div></div>
{% for o in official %}<div class="list-row"><span><b>{{ o.label }}</b> <em class="badge {{ '' if o.active and not o.error else 'critical' }}">{{ o.status }}</em>
<small>{% if o.active %}{{ o.records }} registros · {{ o.version }} · {{ o.sha256 }}{% if o.last_update %} · baixada {{ o.last_update.strftime("%d/%m/%Y") }}{% endif %}{% endif %}{% if o.auto_refresh %} · atualização automática diária{% endif %}{% if o.error %} · {{ o.error }}{% endif %}</small></span></div>{% endfor %}
</article>
</section>
<section class="panel" id="kb-resumo"><div class="panel-title"><div><h2>Base de conhecimento de produtos</h2><p class="muted">Evidência local, histórica e externa; nada vira verdade fiscal sem regra de promoção.</p></div></div>
<div class="coverage"><div class="coverage-item"><strong>{{ kb.products }}</strong><span>Produtos conhecidos</span></div><div class="coverage-item"><strong>{{ kb.verified }}</strong><span>GTINs verificados</span></div><div class="coverage-item"><strong>{{ kb.corroborated }}</strong><span>Corroborados</span></div><div class="coverage-item"><strong>{{ kb.conflicts }}</strong><span>Conflitos</span></div><div class="coverage-item"><strong>{{ kb.hits_today }}</strong><span>Resolvidos pela base hoje</span></div></div>
<form method="get" action="/" class="filters compact-filters"><label>Buscar produto na base (EAN ou nome)<input name="kb_q" value="{{ kb_q or '' }}" placeholder="ex.: COCA COLA LATA 350"></label><button class="secondary" type="submit">Buscar</button>{% if kb_q %}<small class="muted">{{ kb.search.total }} resultado(s) em toda a base{% if kb.search.pages > 1 %} · página {{ kb.search.page }}/{{ kb.search.pages }}{% endif %}</small>{% endif %}</form>
{% for c in kb.search.results %}<div class="list-row"><span><b>{{ c.gtin }}</b> {{ c.description }} · <em class="badge">{{ c.status }}</em>
<small>{% for x in c.candidates %}NCM {{ x.ncm }} — <i>{{ x.official_text or "fora do dataset oficial vigente" }}</i> ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}{% if x.attested %}, decidido por {{ x.attested }}{% endif %}){% if is_admin and x.official_text and c.status != "VERIFIED_KB" %}
<form method="post" action="/kb/attest" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="gtin" value="{{ c.gtin_key }}"><input type="hidden" name="ncm" value="{{ x.ncm }}"><button class="secondary" type="submit" title="Decisão fiscal de um administrador: este é o NCM correto deste EAN">Confirmar este NCM</button></form>{% endif %}<br>{% endfor %}</small></span></div>{% endfor %}
{% if kb_q and kb.search.total == 0 %}<p class="empty-state">Nenhum produto encontrado para "{{ kb_q }}".</p>{% endif %}
{% if kb.search.pages > 1 %}<nav class="pagination" aria-label="Paginação da busca na base">{% if kb.search.page > 1 %}<a href="/?kb_q={{ kb_q|urlencode }}&amp;kb_page={{ kb.search.page - 1 }}#kb-resumo">Anterior</a>{% endif %}<span>Página {{ kb.search.page }} de {{ kb.search.pages }}</span>{% if kb.search.page < kb.search.pages %}<a href="/?kb_q={{ kb_q|urlencode }}&amp;kb_page={{ kb.search.page + 1 }}#kb-resumo">Próxima</a>{% endif %}</nav>{% endif %}
{% for c in kb.recent_conflicts %}<div class="list-row"><span><b>{{ c.gtin }}</b> {{ c.description }}<small>NCMs divergentes, nada é preenchido: {% for x in c.candidates %}{{ x.ncm }} ({{ x.clients|length }} cliente(s), {{ x.documents }} doc.{% if x.providers %}, {{ x.providers|join("/") }}{% endif %}){% if not loop.last %} × {% endif %}{% endfor %}</small></span></div>{% endfor %}</section>
<section class="panel" id="enriquecimento-resumo"><div class="panel-title"><div><h2>Enriquecimento de produtos novos</h2><p class="muted">Cobertura medida sobre os produtos novos acompanhados (o catálogo histórico não entra).</p></div></div>
{% if enrich_metrics.tracked %}<div class="coverage">{% for key,label in [("GTIN_VALID_RATE","Com GTIN válido"),("KB_HIT_RATE","Resolvidos pela base"),("PRODUCT_FOUND_RATE","Produto identificado"),("NCM_AUTO_FILL_SAFE_RATE","NCM preenchível com segurança"),("AMBIGUOUS_RATE","Ambíguos"),("CONFLICT_RATE","Em conflito")] %}<div class="coverage-item"><strong>{{ enrich_metrics[key] if enrich_metrics[key] is not none else "—" }}{% if enrich_metrics[key] is not none %}%{% endif %}</strong><span>{{ label }}</span></div>{% endfor %}</div>{% else %}<p class="empty-state">Nenhum produto novo acompanhado ainda.</p>{% endif %}</section>
<section class="split overview-split">
<article class="panel">
<div class="panel-title"><h2>Cobertura dos produtos</h2><a href="/agents?scope=active">Ver servidores</a></div>
<div class="coverage">
{% for key,label,url in [("total_products","Produtos avaliados","/agents?scope=active"),("fiscally_complete","Completos","/agents?scope=active&amp;sort=worst_coverage"),("safe_blocked","Bloqueados com segurança","/blockers?scope=active&amp;status=OPEN"),("failed_products","Falhas técnicas","/agents?scope=active&amp;health=FAILED"),("unclassified_products","Não classificados","/agents?scope=active&amp;sort=worst_coverage")] %}
<a class="coverage-item" href="{{ url|safe }}" aria-label="{{ label }}: {{ summary[key] }}. Abrir listagem"><strong>{{ summary[key] }}</strong><span>{{ label }}</span></a>
{% endfor %}
</div>
{% if summary.coverage_invariant_pass %}<p class="coverage-status">Cobertura reconciliada: total = completos + bloqueados + falhas + não classificados.</p>{% else %}<p class="coverage-status error">Inconsistência de cobertura: {{ summary.coverage_overcount_products }} acima do total; {{ summary.coverage_invalid_snapshots }} snapshot(s) com contagens negativas.</p>{% endif %}
<div class="registry-summary"><a href="/blockers?scope=active&amp;status=OPEN">Produtos com blockers abertos: <strong>{{ summary.safe_blocked_products }}</strong></a><span>Blockers abertos: <strong>{{ summary.open_blockers }}</strong></span></div>
</article>
<article class="panel">
<div class="panel-title"><h2>Breakdown dos blockers</h2><a href="/blockers?scope=active&amp;status=OPEN">Ver blockers</a></div>
{% for key in ["NCM","CEST","CFOP","ST","ICMS","CSOSN","PIS","COFINS","RTC","OTHER"] %}
{% if summary.open_blocker_breakdown[key] > 0 %}<a class="bar-row bar-link" href="/blockers?scope=active&amp;status=OPEN&amp;field={{ key }}" aria-label="{{ key }}: {{ summary.open_blocker_breakdown[key] }} blockers abertos"><span>{{ key }}</span><b>{{ summary.open_blocker_breakdown[key] }}</b><span aria-hidden="true">→</span></a>
{% else %}<div class="bar-row muted"><span>{{ key }}</span><b>0</b></div>{% endif %}
{% endfor %}
</article>
</section>
<section class="panel attention">
<div class="panel-title"><div><p class="eyebrow">FILA DE AÇÃO</p><h2>Atenção necessária</h2></div><a href="/blockers?scope=active&amp;status=OPEN">Ver blockers</a></div>
{% for item in summary.attention_items %}
<a class="list-row attention-row" href="{{ item.link }}"><span><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.hostname or item.display_name or "Servidor sem nome" }} · {{ item.problem }}</small>{% if item.group_count %}<small>{{ item.products_affected }} produtos afetados · {{ item.group_count }} blockers abertos · Ver blockers →</small>{% endif %}</span><em class="badge {{ 'priority-' ~ item.priority|lower if item.priority else item.technical_health|lower }}">{{ item.age }}</em></a>
{% else %}<p class="empty-state">Nenhum item requer atenção imediata.</p>{% endfor %}
</section>
<section class="split jobs-overview">
<article class="panel">
<div class="panel-title"><h2>Jobs com falha</h2><a href="/jobs?scope=active&amp;status=FAILED">Ver jobs</a></div>
{% if summary.failed_jobs %}<p class="metric-inline"><strong>{{ summary.failed_jobs }}</strong> jobs aguardam investigação.</p>
<ul class="failed-jobs" aria-label="Falhas recentes (máximo 10)">
{% for job in summary.recent_failed_jobs %}<li class="failed-job"><div class="failed-job-main"><b>{{ job.client or "Cliente não identificado" }}</b><small>{{ job.display_name or "Instalação" }} · {{ job.hostname or "Servidor não informado" }}</small><span class="failed-job-kind">{{ job.kind_label }}</span><small class="error-text job-error-summary" title="{{ job.error or '' }}">{{ job.error or "Sem detalhe de erro" }}</small></div><div class="failed-job-side"><span title="{{ job.finished_at }}">{{ job.finished_age }}</span><small>{{ job.finished_display }}</small><a href="/jobs?scope=active&amp;status=FAILED&amp;job_id={{ job.job_id }}">Ver detalhes</a></div></li>{% endfor %}
</ul>{% else %}<p class="empty-state">Nenhum job com falha.</p>{% endif %}
</article>
<article class="panel">
<div class="panel-title"><h2>Agents desatualizados</h2><a href="/agents?scope=active&amp;version=outdated">Ver servidores</a></div>
{% for item in summary.outdated_agents[:5] %}<a class="list-row" href="/agents/{{ item.agent_id }}"><span><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}</small><small>{{ item.agent_version or "Versão não informada" }} → atualização disponível</small></span><em class="badge warning">Desatualizado</em></a>{% else %}<p class="empty-state">Nenhum Agent desatualizado.</p>{% endfor %}
</article>
</section>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}Jobs | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading"><div><p class="eyebrow">EXECUÇÕES</p><h1>Jobs</h1><p class="muted">Acompanhe execuções ativas e investigue falhas sem perder o histórico.</p></div></div>
<section class="cards compact jobs-kpis" aria-label="Jobs por status">
{% for value,label in [("PENDING","Pendentes"),("RUNNING","Executando"),("COMPLETED","Concluídos"),("FAILED","Falhos")] %}<a class="metric metric-link{% if status_filter==value %} metric-selected{% endif %}" href="/jobs?scope={{ scope_filter }}&amp;status={{ value }}"><span>{{ label }}</span><strong>{{ status_counts[value] }}</strong><small>Ver jobs →</small></a>{% endfor %}
</section>
<section class="panel filter-panel"><form class="filters" method="get">
<label>Status<select name="status"><option value="">Todos</option>{% for value,label in [("PENDING","Pendente"),("RUNNING","Executando"),("COMPLETED","Concluído"),("FAILED","Falhou")] %}<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>
<label class="filter-search">Cliente, instalação ou busca<input name="search" value="{{ search }}" type="search"></label>
<label>Tipo<input name="kind" value="{{ kind_filter }}" placeholder="Ex.: SYNC_INCREMENTAL"></label>
<label>Visualização<select name="scope"><option value="active" {% if scope_filter=="active" %}selected{% endif %}>Operação ativa</option><option value="all" {% if scope_filter=="all" %}selected{% endif %}>Histórico e inativos</option></select></label>
{% if job_id_filter %}<input type="hidden" name="job_id" value="{{ job_id_filter }}">{% endif %}
<button class="secondary" type="submit">Filtrar jobs</button>
{% if status_filter or search or kind_filter or job_id_filter or scope_filter=="all" %}<a class="secondary filter-clear" href="/jobs?scope=active">Limpar filtros</a>{% endif %}
</form>{% if status_filter or job_id_filter or scope_filter=="all" %}<p class="active-filters" aria-live="polite">Filtros ativos: {% if scope_filter=="all" %}<span class="filter-chip">Histórico e inativos</span>{% endif %}{% if status_filter %}<span class="filter-chip">Status: {{ {"PENDING":"Pendente","RUNNING":"Executando","COMPLETED":"Concluído","FAILED":"Falhou"}[status_filter] }}</span>{% endif %}{% if job_id_filter %}<span class="filter-chip">Job #{{ job_id_filter }}</span>{% endif %}</p>{% endif %}</section>
{% if job_id_filter and result["items"] %}{% set selected = result["items"][0] %}<section class="panel job-detail"><div class="panel-title"><h2>Job #{{ selected.job_id }}</h2><a href="/jobs?scope={{ scope_filter }}{% if status_filter %}&amp;status={{ status_filter }}{% endif %}">Voltar à lista</a></div><p><b>{{ selected.client or "Cliente não identificado" }}</b> · {{ selected.display_name or "Instalação" }} · {{ selected.hostname or "Servidor não informado" }}</p><p>{{ selected.kind_label }} <span class="badge status-{{ selected.status|lower }}">{{ selected.status_label }}</span></p><p><b>Erro completo</b></p><pre class="job-error-full">{{ selected.error or "Sem erro registrado" }}</pre><small>Tipo técnico: {{ selected.kind }} · Status técnico: {{ selected.status }}</small></section>{% endif %}
<div class="panel table-wrap jobs-table-wrap"><table class="jobs-table"><caption>Execuções recentes · {{ result.total }} resultados</caption><thead><tr><th>Cliente / servidor</th><th>Tipo / job</th><th>Status</th><th>Criado</th><th>Finalizado / duração</th><th>Erro / ação</th></tr></thead><tbody>
{% for item in result["items"] %}<tr class="{% if item.status=='FAILED' %}job-failed-row{% endif %}"><td data-label="Cliente / servidor"><a href="/agents/{{ item.agent_id }}"><b>{{ item.client or "Cliente não identificado" }}</b><small>{{ item.display_name or "Instalação" }} · {{ item.hostname or "Servidor não informado" }}</small></a></td><td data-label="Tipo / job">{{ item.kind_label }}<small>Job #{{ item.job_id }}</small></td><td data-label="Status"><span class="badge status-{{ item.status|lower }}">{{ item.status_label }}</span></td><td data-label="Criado" title="{{ item.created_at }}">{{ item.created_display }}</td><td data-label="Finalizado / duração" title="{{ item.finished_at }}">{{ item.finished_display }}<small>{{ item.duration }}</small></td><td data-label="Erro / ação" class="{% if item.status=='FAILED' %}error-text{% endif %}"><span class="error-summary">{{ (item.error or "")[:100] }}{% if item.error and item.error|length > 100 %}…{% endif %}</span><a href="/jobs?scope={{ scope_filter }}&amp;job_id={{ item.job_id }}">Ver detalhes</a></td></tr>{% else %}<tr><td colspan="6" class="empty-state">Nenhum job encontrado. <a href="/jobs?scope=all">Consultar histórico</a></td></tr>{% endfor %}
</tbody></table></div>
<nav class="pagination" aria-label="Paginação de jobs">{% if result.page > 1 %}<a href="/jobs?page={{ result.page - 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Anterior</a>{% endif %}<span>Página {{ result.page }} · {{ result.total }} jobs</span>{% if result.page * result.page_size < result.total %}<a href="/jobs?page={{ result.page + 1 }}{% if query_base %}&amp;{{ query_base|e }}{% endif %}">Próxima</a>{% endif %}</nav>
{% endblock %}
+22
View File
@@ -0,0 +1,22 @@
<!doctype html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Você saiu | GTI Fiscal</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
</head>
<body class="login-page">
<div class="cartao login-card">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>Você saiu do GTI Fiscal</h1>
<p class="login-subtitle">Sua sessão no Painel de Clientes continua ativa.</p>
<div class="login-actions">
{% if panel_home %}<a class="botao" href="{{ panel_home }}">Voltar ao Painel de Clientes</a>{% endif %}
<a class="botao" href="/login">Entrar novamente no Fiscal</a>
</div>
</div>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>{% if emergency %}Acesso de emergência{% else %}Entrar{% endif %} | GTI Fiscal</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
</head>
<body class="login-page">
<div class="cartao login-card">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>GTI Fiscal</h1>
{% if emergency %}
<p class="login-subtitle">Acesso de emergência (conta local). Colaboradores entram pelo Painel de Clientes.</p>
{% else %}
<p class="login-subtitle">Entre com suas credenciais para acessar o sistema</p>
{% endif %}
{% if error %}<div class="mensagem danger">{{ error }}</div>{% endif %}
<form method="post" action="/login">
<label for="login-username">Usuário</label>
<input id="login-username" name="username" autocomplete="username" required autofocus>
<label for="login-password">Senha</label>
<input id="login-password" name="password" type="password" autocomplete="current-password" required>
<button type="submit">Entrar</button>
</form>
</div>
</body>
</html>
@@ -0,0 +1,71 @@
{% extends "base.html" %}
{% block title %}Tabelas oficiais | GTI Fiscal{% endblock %}
{% block content %}
<div class="page-heading">
<div><p class="eyebrow">CATÁLOGO DE REFERÊNCIA FISCAL</p><h1>Tabelas oficiais</h1><p class="muted">Nenhuma versão vira "vigente" sem aprovação de um administrador do diff.</p></div>
</div>
<section class="panel">
{% for r in rows %}
<article class="panel" style="margin-top:12px"><div class="panel-title"><h3>{{ r.kind }} · {{ r.version_label }}</h3><span class="badge {{ '' if r.status in ['VIGENTE','ACTIVE'] else ('critical' if r.status in ['REJECTED'] else '') }}">{{ r.status }}</span></div>
<dl class="facts">
<dt>Fonte</dt><dd>{{ r.provider }} — <a href="{{ r.source_url }}" target="_blank" rel="noopener">{{ r.source_url }}</a></dd>
<dt>Linhas</dt><dd>{{ r.record_count }}</dd>
<dt>Baixado em</dt><dd>{{ r.downloaded_at }}{% if r.submitted_by %} · por {{ r.submitted_by }}{% endif %}</dd>
{% if r.licence_terms %}<dt>Licença/termos</dt><dd>{{ r.licence_terms }}</dd>{% endif %}
{% if r.parse_errors %}<dt>Erros de parse</dt><dd class="coverage-status error">{{ r.parse_errors }}</dd>{% endif %}
{% if r.approved_by %}<dt>Decidido por</dt><dd>{{ r.approved_by }}</dd>{% endif %}
{% if r.diff %}
<dt>Diff vs. vigente atual</dt>
<dd>
{% if r.diff.is_first_version %}Primeira versão deste tipo de dataset — nada para comparar.
{% else %}Vigente atual: {{ r.diff.current_vigente.version_label }} ({{ r.diff.current_vigente.record_count }} linhas) →
proposto: {{ r.diff.draft.version_label }} ({{ r.diff.draft.record_count }} linhas, diferença de {{ r.diff.record_count_delta }})
{% if r.diff.sha256_unchanged %} · conteúdo idêntico (mesmo hash){% endif %}{% endif %}
</dd>
{% endif %}
</dl>
{% if r.checklist %}
<div class="technical-checklist" style="background:#f8f9fa; border:1px solid #e2e8f0; border-radius:6px; padding:12px; margin:12px 0;">
<h4 style="margin-top:0; margin-bottom:8px; font-size:14px; text-transform:uppercase; color:#475569;">Checklist Técnico de Aprovação (Sem Julgamento Fiscal Exigido)</h4>
<ul style="list-style:none; padding-left:0; margin-bottom:10px; font-size:13px; line-height:1.6;">
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.official_source.passed else 'red' }}">[{{ '✓' if r.checklist.official_source.passed else '✗' }}]</span>
<strong>1. Fonte Oficial:</strong> {{ r.checklist.official_source.provider }} · <a href="{{ r.checklist.official_source.url }}" target="_blank" rel="noopener">{{ r.checklist.official_source.url }}</a> · <em>{{ r.checklist.official_source.act }}</em>
</li>
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.integrity.passed else 'red' }}">[{{ '✓' if r.checklist.integrity.passed else '✗' }}]</span>
<strong>2. Hash SHA-256 e Tamanho:</strong> <code style="font-size:11px;">{{ r.checklist.integrity.sha256 }}</code> {% if r.checklist.integrity.file_size_bytes %}({{ r.checklist.integrity.file_size_bytes }} bytes){% endif %}
</li>
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.volume.passed else 'red' }}">[{{ '✓' if r.checklist.volume.passed else '✗' }}]</span>
<strong>3. Contagem de Linhas:</strong> {{ r.checklist.volume.record_count }} linhas registradas (esperado &ge; {{ r.checklist.volume.expected_minimum }})
</li>
<li>
<span style="font-weight:bold; color:{{ 'green' if r.checklist.no_parse_errors else 'red' }}">[{{ '✓' if r.checklist.no_parse_errors else '✗' }}]</span>
<strong>4. Erros de Parse:</strong> {{ 'Nenhum erro de parse (deterministico)' if r.checklist.no_parse_errors else 'Existem erros de parse no processamento!' }}
</li>
<li>
<span style="font-weight:bold; color:green">[✓]</span>
<strong>5. Amostra de 5 Consultas com Resultado Conhecido:</strong>
<ol style="margin-top:4px; margin-bottom:4px; padding-left:20px;">
{% for code, label in r.checklist.samples %}
<li><code>{{ code }}</code>: {{ label }}</li>
{% endfor %}
</ol>
</li>
</ul>
</div>
{% endif %}
{% if r.status == "DRAFT" and is_admin %}
<form method="post" action="/admin/reference-datasets/{{ r.id }}/decide" class="filters compact-filters">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button class="secondary" type="submit" name="action" value="approve" {% if not r.checklist.ready_to_approve %}disabled title="Checklist técnico não satisfeito ou erros presentes"{% endif %}>Aprovar (vira vigente)</button>
<button class="secondary" type="submit" name="action" value="reject" onclick="return confirm('Rejeitar esta versão?')">Rejeitar</button>
</form>
{% endif %}
</article>
{% else %}
<p class="muted">Nenhum dataset registrado no catálogo novo ainda.</p>
{% endfor %}
</section>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "base.html" %}
{% block title %}Confirmar reprocessamento | GTI Fiscal{% endblock %}
{% block content %}
<section class="panel">
<p class="eyebrow">AÇÃO OPERACIONAL</p>
<h1>Confirmar reprocessamento</h1>
<p>O reprocessamento completo será enfileirado para esta instalação.</p>
{% if error %}<p class="error" role="alert">{{ error }}</p>{% endif %}
<form method="post" action="/agents/{{ agent_id }}/reprocess">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<label><input type="checkbox" name="confirm" value="yes" required> Confirmo reprocessar todos os produtos desta instalação.</label>
<p><button type="submit">Confirmar e enfileirar</button> <a href="/agents/{{ agent_id }}">Cancelar</a></p>
</form>
</section>
{% endblock %}
+55
View File
@@ -0,0 +1,55 @@
{% extends "base.html" %}
{% block title %}cClassTrib | GTI Fiscal{% endblock %}
{% block content %}
<div class="detail-head"><div><p class="eyebrow"><a href="/agents/{{ agent_id }}">← Instalação</a> / REFORMA TRIBUTÁRIA</p><h1>Preenchimento de cClassTrib</h1>
<p class="muted">{{ explanation }}</p>{% if not privileged %}<p class="muted"><strong>Somente leitura para o seu perfil:</strong> alterar para aprovação/automático, aprovar, aplicar e reverter exige administrador do GTI Gerenciador.</p>{% endif %}</div></div>
{% set rtc = ops.rtc %}
{% if rtc and rtc.products and not rtc.products.applicable %}
<p class="muted" role="status">{{ rtc.products.label }}</p>
{% elif rtc and rtc.products %}
<section class="cards" aria-label="Resumo cClassTrib">
<article class="metric"><span>Produtos</span><strong>{{ rtc.products.total }}</strong></article>
<article class="metric"><span>cClassTrib válido</span><strong>{{ rtc.products.valid }}</strong></article>
<article class="metric"><span>Auto-resolvíveis</span><strong>{{ rtc.products.ready }}</strong></article>
<article class="metric"><span>Bloqueados</span><strong>{{ rtc.products.blocked }}</strong></article>
<article class="metric"><span>Conflitos</span><strong>{{ rtc.products.conflicts }}</strong></article>
<article class="metric"><span>Catálogo incompatível</span><strong>{{ rtc.products.catalog_incompatible }}</strong></article>
</section>
{% endif %}
{% if rtc_empty_state.show %}
<section class="panel rtc-empty-state" role="status" aria-live="polite">
<div class="rtc-empty-copy">
<h2>Não há produtos elegíveis para preenchimento automático.</h2>
<p>{{ rtc_empty_state.blocked_products }} produto{{ 's' if rtc_empty_state.blocked_products != 1 else '' }} {% if rtc_empty_state.blocked_products != 1 %}precisam de atenção fiscal, mas não podem ser preenchidos{% else %}precisa de atenção fiscal, mas não pode ser preenchido{% endif %} automaticamente pelas regras atuais.</p>
{% if rtc_empty_state.simple_orientation_dominant %}
<p>Esta empresa está no Simples Nacional. Os produtos estão bloqueados porque o sistema ainda não possui fundamento oficial suficiente para determinar automaticamente o cClassTrib. Alterar o modo de preenchimento não remove esse bloqueio.</p>
{% endif %}
</div>
<a class="botao" href="/blockers?scope=all&amp;agent_id={{ agent_id }}&amp;field=RTC&amp;status=OPEN">Ver produtos bloqueados ({{ rtc_empty_state.blocked_products }})</a>
</section>
{% endif %}
<section class="panel"><div class="panel-title"><div><h2>Modo de preenchimento</h2><p class="muted">Decidido centralmente para esta instalação. Padrão: Desativada. Nenhuma configuração local no servidor do cliente.</p></div></div>
<form method="post" action="/agents/{{ agent_id }}/rtc-mode"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{% for key, label in modes.items() %}<label style="display:block"><input type="radio" name="mode" value="{{ key }}" {% if key == mode %}checked{% endif %} {% if not privileged and key not in ("DISABLED", "DRY_RUN") %}disabled{% endif %}> {{ label }}
{% if key == "DISABLED" %}— apenas informa{% elif key == "DRY_RUN" %}— mostra os candidatos, não escreve{% elif key == "APPROVAL_REQUIRED" %}— escreve só o que for aprovado abaixo{% else %}— preenche os candidatos determinísticos{% endif %}</label>{% endfor %}
<p><input type="text" name="confirm" placeholder="digite PREENCHER (modo automático)" aria-label="Confirmar modo automático" size="30"> <button type="submit" class="primary-action">Salvar modo</button></p></form></section>
<section class="panel"><div class="panel-title"><div><h2>Candidatos</h2><p class="muted">{{ total }} produto(s) neste filtro.</p></div></div>
<nav class="tabs" aria-label="Filtro de candidatos">{% for st, label in [("PENDING","Pendentes"),("APPROVED","Aprovados"),("REJECTED","Rejeitados"),("APPLIED","Aplicados"),("STALE","Obsoletos")] %}<a class="{% if status==st %}active{% endif %}" href="?status={{ st }}">{{ label }} ({{ counts.get(st, 0) }})</a>{% endfor %}</nav>
<form method="post" action="/agents/{{ agent_id }}/rtc-candidates"><input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="table-wrap"><table class="compact-table"><thead><tr><th></th><th>Produto</th><th>Descrição</th><th>NCM</th><th>cClassTrib atual</th><th>cClassTrib proposto</th><th>CST IBS/CBS</th><th>Regra</th><th>Fundamento</th><th>Catálogo</th><th>Situação</th></tr></thead><tbody>
{% for c in rows %}<tr><td><input type="checkbox" name="ids" value="{{ c.id }}" aria-label="Selecionar {{ c.product_code }}"></td><td>{{ c.product_code }}</td><td>{{ c.description or "" }}</td><td>{{ c.ncm }}</td><td>{{ c.current_value or "vazio" }}</td><td>{{ c.proposed }}</td><td>{{ c.cst_ibscbs }}</td><td>{{ L.rule(c.rule_id) }}</td><td>{{ c.basis }}</td><td>{{ L.catalog("MATCH_SAFE") }}</td><td>{{ L.status(c.status) }}</td></tr>
{% else %}<tr><td colspan="10" class="muted">Nenhum candidato neste filtro.</td></tr>{% endfor %}</tbody></table></div>
<p>Página {{ page }} de {{ pages }}{% if page > 1 %} · <a href="?status={{ status }}&amp;page={{ page - 1 }}">anterior</a>{% endif %}{% if page < pages %} · <a href="?status={{ status }}&amp;page={{ page + 1 }}">próxima</a>{% endif %}</p>
<p><button type="submit" name="action" value="approve" class="secondary" {% if not privileged %}disabled{% endif %}>Aprovar selecionados</button> <button type="submit" name="action" value="reject" class="caution">Rejeitar selecionados</button>
<button type="submit" name="action" value="approve" class="secondary" formaction="/agents/{{ agent_id }}/rtc-candidates?all=1">Aprovar todos os pendentes</button>
{% if mode == "APPROVAL_REQUIRED" %}<button type="submit" name="action" value="apply_approved" class="primary-action">Aplicar aprovados agora</button>{% endif %}</p></form></section>
<section class="panel"><div class="panel-title"><div><h2>Auditoria e reversão</h2><p class="muted">Cada preenchimento pode ser revertido individualmente, desde que o valor no ERP ainda seja o gravado.</p></div></div>
<div class="table-wrap"><table class="compact-table"><thead><tr><th>#</th><th>Produto</th><th>Ação</th><th>Resultado</th><th>Antes → Depois</th><th>Regra</th><th>Origem</th><th>Reversão</th></tr></thead><tbody>
{% for a in audits %}<tr><td>{{ a.id }}</td><td>{{ a.product_code }} · {{ a.description_snapshot or "" }}</td><td>{{ "Reversão" if a.action == "ROLLBACK" else "Preenchimento" }}</td><td>{{ L.outcome(a.outcome) }}{% if a.reason %} ({{ L.reason(a.reason) }}){% endif %}</td><td>{{ a.old_value or "vazio" }} → {{ a.new_value or "vazio" }}</td><td>{{ L.rule(a.rule_id) }}</td><td>{{ a.authorization_origin }}</td>
<td>{% if a.action == "APPLY" and a.outcome == "APPLIED" and not a.rollback_state %}<form method="post" action="/agents/{{ agent_id }}/rtc-rollback" style="display:inline"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="audit_id" value="{{ a.id }}"><input type="text" name="confirm" placeholder="digite REVERTER" size="12" aria-label="Confirmar reversão"><button type="submit" class="caution">Reverter</button></form>{% else %}{{ a.rollback_state or "" }}{% endif %}</td></tr>
{% else %}<tr><td colspan="8" class="muted">Nenhum preenchimento registrado.</td></tr>{% endfor %}</tbody></table></div></section>
{% endblock %}
+30
View File
@@ -0,0 +1,30 @@
<!doctype html>
<html lang="pt-BR" data-tema="escuro">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Não foi possível entrar | GTI Fiscal</title>
<link rel="icon" type="image/png" href="/static/logo-gti-256x256.png">
<link rel="stylesheet" href="/static/gti-theme.css">
<link rel="stylesheet" href="/static/control-panel.css">
</head>
<body class="login-page">
<div class="cartao login-card">
<img src="/static/logo-gti-256x256.png" alt="GTI Grupo" class="login-logo">
<h1>Não foi possível entrar no GTI Fiscal</h1>
{% set messages = {
"EXPIRED": "O acesso demorou demais para ser concluído.",
"STATE_MISMATCH": "O acesso foi iniciado em outra aba ou já foi usado.",
"REPLAY": "Este acesso já foi utilizado.",
"DISABLED_IN_FISCAL": "Seu usuário está desativado no GTI Fiscal.",
"SSO_DISABLED": "O acesso pelo Painel de Clientes não está configurado."
} %}
<p class="login-subtitle">{{ messages.get(reason, "A identificação enviada pelo Painel de Clientes não foi aceita.") }}</p>
<div class="login-actions">
<a class="botao" href="/login">Tentar novamente</a>
{% if panel_home %}<a class="botao" href="{{ panel_home }}">Voltar ao Painel de Clientes</a>{% endif %}
</div>
<p class="muted" style="margin-top:16px;font-size:.78rem">Código: {{ reason }}</p>
</div>
</body>
</html>