Browse Source

fix(assinatura): Migrar assinatura digital para o microserviço [task AB#1349 issue AB#1347]

pull/3858/head
KemuelAlves 4 months ago
parent
commit
6a26350c03
  1. 345
      sapl/materia/assinatura_api_client.py
  2. 11
      sapl/materia/urls.py
  3. 1764
      sapl/materia/views_assinatura.py
  4. 7
      sapl/settings.py
  5. 49
      sapl/templates/materia/assinatura_modal.html
  6. 28
      sapl/templates/materia/documentoacessorio_list.html
  7. 28
      sapl/templates/materia/materialegislativa_filter.html

345
sapl/materia/assinatura_api_client.py

@ -0,0 +1,345 @@
"""
Cliente para o pyHanko Sign Service (microserviço externo de assinatura digital).
Contrato real da API (descoberto via /openapi.json):
POST /sign multipart/form-data
pdf : arquivo PDF (binário)
pfx : arquivo .pfx/.p12 (binário)
pfx_password : senha do PFX
reason : motivo (opcional)
location : local (opcional)
signature_page : página 1-based (opcional)
signature_left/bottom/width/height : posição em pontos PDF (opcional)
Resposta 200: Content-Type: application/pdf bytes do PDF assinado
Resposta 4xx/5xx: JSON com campo "detail"
Outros endpoints:
POST /validate-pfx valida certificado PFX
POST /validate valida assinaturas de um PDF
POST /sign/batch assina múltiplos PDFs em lote
GET / health check
"""
import logging
import requests
from django.conf import settings
logger = logging.getLogger(__name__)
class AssinaturaAPIError(Exception):
"""Erro retornado pelo microserviço de assinatura."""
def __init__(self, message, status_code=None):
super().__init__(message)
self.status_code = status_code
def _api_configurada():
"""Retorna True se a API externa de assinatura está configurada."""
return bool(getattr(settings, 'ASSINATURA_API_URL', '').strip())
def _montar_headers():
"""Authorization só é adicionado se ASSINATURA_API_KEY estiver preenchido."""
headers = {}
api_key = getattr(settings, 'ASSINATURA_API_KEY', '').strip()
if api_key:
headers['Authorization'] = f'Bearer {api_key}'
return headers
def _url(endpoint):
base = settings.ASSINATURA_API_URL.rstrip('/')
endpoint = endpoint.lstrip('/')
return f'{base}/{endpoint}'
def assinar_pdf_via_api(pdf_bytes, *, certificado_bytes, senha,
reason=None, location=None,
signature_page=None,
signature_left=None, signature_bottom=None,
signature_width=None, signature_height=None):
"""
Envia o PDF ao pyHanko Sign Service e retorna o PDF assinado como bytes.
Parâmetros
----------
pdf_bytes : bytes PDF a assinar
certificado_bytes : bytes arquivo .pfx/.p12
senha : str senha do certificado
reason : str motivo (exibido na assinatura visual)
location : str local (exibido na assinatura visual)
signature_page : int página 1-based (None = última)
signature_left/bottom/width/height : float coordenadas em pontos PDF
Retorna: bytes do PDF assinado.
Lança: AssinaturaAPIError em caso de erro.
"""
if not _api_configurada():
raise AssinaturaAPIError(
'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).'
)
files = {
'pdf': ('documento.pdf', pdf_bytes, 'application/pdf'),
'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream'),
}
data = {'pfx_password': senha}
if reason:
data['reason'] = reason
if location:
data['location'] = location
if signature_page is not None:
data['signature_page'] = str(signature_page)
if signature_left is not None:
data['signature_left'] = str(signature_left)
if signature_bottom is not None:
data['signature_bottom'] = str(signature_bottom)
if signature_width is not None:
data['signature_width'] = str(signature_width)
if signature_height is not None:
data['signature_height'] = str(signature_height)
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120)
try:
response = requests.post(
_url('sign'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.ConnectionError as exc:
logger.error(f'[assinatura-api] Falha de conexao: {exc}')
raise AssinaturaAPIError(
'Nao foi possivel conectar ao microservico de assinatura. '
'Verifique se o servico esta disponivel.'
)
except requests.exceptions.Timeout:
raise AssinaturaAPIError(
f'Timeout ao aguardar resposta do microservico de assinatura '
f'(limite: {timeout}s).'
)
except requests.exceptions.RequestException as exc:
logger.error(f'[assinatura-api] Erro inesperado: {exc}')
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or detail.get('error') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
logger.error(f'[assinatura-api] Erro HTTP {response.status_code}: {msg}')
raise AssinaturaAPIError(msg, status_code=response.status_code)
# A API retorna o PDF assinado diretamente como application/pdf
content_type = response.headers.get('Content-Type', '')
if 'pdf' not in content_type and len(response.content) < 100:
raise AssinaturaAPIError(
f'Resposta inesperada do microservico (Content-Type: {content_type}).'
)
logger.info('[assinatura-api] PDF assinado com sucesso pelo microservico.')
return response.content
def assinar_pdf_lote_via_api(itens, *, certificado_bytes, senha,
reason=None, location=None,
download_workers=8):
"""
Assina múltiplos PDFs em uma única chamada POST /sign/batch e baixa os
resultados em paralelo via download_url do S3.
Parâmetros
----------
itens : list[dict] cada item deve ter:
'id' : identificador (qualquer hashable preservado no resultado)
'pdf_bytes' : bytes do PDF a assinar
'signature_page' : int 1-based (opcional, mesmo para todos)
'signature_left' : float (opcional)
'signature_bottom': float (opcional)
'signature_width' : float (opcional)
'signature_height': float (opcional)
certificado_bytes : bytes arquivo .pfx/.p12 (compartilhado por todos)
senha : str senha do certificado
reason, location : str metadados da assinatura
download_workers : int threads para baixar resultados do S3 (default 8)
Retorna: list[dict] na mesma ordem de `itens`, com campos:
'id' : o mesmo id do item de entrada
'ok' : True / False
'pdf_bytes' : bytes do PDF assinado (apenas quando ok=True)
'error' : mensagem de erro (apenas quando ok=False)
"""
if not _api_configurada():
raise AssinaturaAPIError(
'Microserviço de assinatura não configurado (ASSINATURA_API_URL vazio).'
)
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120)
# ── 1. Enviar todos os PDFs em uma única chamada /sign/batch ─────────────
# O campo 'signature_page/left/bottom/width/height' é único para o lote —
# usamos os valores do primeiro item (todos partilham a mesma posição).
primeiro = itens[0] if itens else {}
data = {'pfx_password': senha}
if reason:
data['reason'] = reason
if location:
data['location'] = location
if primeiro.get('signature_page') is not None:
data['signature_page'] = str(primeiro['signature_page'])
if primeiro.get('signature_left') is not None:
data['signature_left'] = str(primeiro['signature_left'])
if primeiro.get('signature_bottom') is not None:
data['signature_bottom'] = str(primeiro['signature_bottom'])
if primeiro.get('signature_width') is not None:
data['signature_width'] = str(primeiro['signature_width'])
if primeiro.get('signature_height') is not None:
data['signature_height'] = str(primeiro['signature_height'])
# multipart: múltiplos campos 'pdfs' + um 'pfx'
files = [('pfx', ('certificado.pfx', certificado_bytes, 'application/octet-stream'))]
for idx, item in enumerate(itens):
filename = f'doc{idx + 1}.pdf'
files.append(('pdfs', (filename, item['pdf_bytes'], 'application/pdf')))
try:
response = requests.post(
_url('sign/batch'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.ConnectionError as exc:
logger.error(f'[assinatura-api/batch] Falha de conexão: {exc}')
raise AssinaturaAPIError(
'Não foi possível conectar ao microserviço de assinatura.'
)
except requests.exceptions.Timeout:
raise AssinaturaAPIError(
f'Timeout ao aguardar resposta do microserviço (limite: {timeout}s).'
)
except requests.exceptions.RequestException as exc:
raise AssinaturaAPIError(f'Erro ao comunicar com o microserviço: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
logger.error(f'[assinatura-api/batch] Erro HTTP {response.status_code}: {msg}')
raise AssinaturaAPIError(msg, status_code=response.status_code)
try:
batch_result = response.json()
except Exception:
raise AssinaturaAPIError('Resposta do /sign/batch não é JSON válido.')
resultados_api = batch_result.get('results', [])
if len(resultados_api) != len(itens):
raise AssinaturaAPIError(
f'Resposta do /sign/batch retornou {len(resultados_api)} itens, '
f'esperado {len(itens)}.'
)
logger.info(f'[assinatura-api/batch] {len(resultados_api)} PDFs assinados. Baixando...')
# ── 2. Baixar PDFs assinados em paralelo via download_url ─────────────────
from concurrent.futures import ThreadPoolExecutor, as_completed
def _baixar(idx_url):
idx, url = idx_url
try:
r = requests.get(url, timeout=60)
if not r.ok:
return idx, None, f'Erro ao baixar PDF assinado: HTTP {r.status_code}'
if not r.content[:5] == b'%PDF-':
return idx, None, 'Conteúdo baixado não é um PDF válido.'
return idx, r.content, None
except Exception as exc:
return idx, None, str(exc)
urls_indexadas = [
(idx, res['download_url'])
for idx, res in enumerate(resultados_api)
]
resultados_finais = [None] * len(itens)
with ThreadPoolExecutor(max_workers=download_workers) as executor:
futures = {executor.submit(_baixar, item): item for item in urls_indexadas}
for future in as_completed(futures):
idx, pdf_bytes, error = future.result()
item_id = itens[idx]['id']
if error:
logger.error(f'[assinatura-api/batch] item {idx} (id={item_id}): {error}')
resultados_finais[idx] = {'id': item_id, 'ok': False, 'error': error}
else:
resultados_finais[idx] = {'id': item_id, 'ok': True, 'pdf_bytes': pdf_bytes}
return resultados_finais
def validar_pfx_via_api(certificado_bytes, senha):
"""
Valida um certificado PFX no microservico.
Retorna dict com informacoes do certificado ou lanca AssinaturaAPIError.
"""
if not _api_configurada():
raise AssinaturaAPIError('ASSINATURA_API_URL nao configurado.')
files = {'pfx': ('certificado.pfx', certificado_bytes, 'application/octet-stream')}
data = {'pfx_password': senha}
timeout = getattr(settings, 'ASSINATURA_API_TIMEOUT', 120)
try:
response = requests.post(
_url('validate-pfx'),
files=files,
data=data,
headers=_montar_headers(),
timeout=timeout,
)
except requests.exceptions.RequestException as exc:
raise AssinaturaAPIError(f'Erro ao comunicar com o microservico: {exc}')
if not response.ok:
try:
detail = response.json()
msg = detail.get('detail') or str(detail)
except Exception:
msg = response.text[:300] or f'HTTP {response.status_code}'
raise AssinaturaAPIError(msg, status_code=response.status_code)
try:
return response.json()
except Exception:
return {}
def verificar_health():
"""
Verifica se o microservico esta disponivel (GET /).
Retorna (ok: bool, mensagem: str).
"""
if not _api_configurada():
return False, 'ASSINATURA_API_URL nao configurado.'
try:
response = requests.get(
_url('/'),
headers=_montar_headers(),
timeout=10,
)
if response.ok:
return True, 'Microservico disponivel.'
return False, f'Microservico respondeu com HTTP {response.status_code}.'
except requests.exceptions.RequestException as exc:
return False, f'Falha de conexao: {exc}'

11
sapl/materia/urls.py

@ -53,11 +53,12 @@ from sapl.materia.onlyoffice_materia_views import (
from sapl.materia.views_assinatura import ( from sapl.materia.views_assinatura import (
materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar, materia_assinar_a1, materia_assinar_a3_preparar, materia_assinar_a3_finalizar,
materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura, materia_pdf_assinado, materia_verificar_assinatura, materia_remover_assinatura,
detectar_aplicacao_a3, materia_assinar_lote, detectar_aplicacao_a3, assinatura_api_status, materia_assinar_lote,
docacessorio_assinar_a1, docacessorio_pdf_assinado, docacessorio_assinar_a1, docacessorio_pdf_assinado,
docacessorio_verificar_assinatura, docacessorio_remover_assinatura, docacessorio_verificar_assinatura, docacessorio_remover_assinatura,
materia_verificar_documento, docacessorio_verificar_documento, materia_verificar_documento, docacessorio_verificar_documento,
docacessorio_assinar_lote docacessorio_assinar_lote,
materia_pagina_assinatura_png, docacessorio_pagina_assinatura_png,
) )
from sapl.norma.views import NormaPesquisaSimplesView from sapl.norma.views import NormaPesquisaSimplesView
from sapl.protocoloadm.views import ( from sapl.protocoloadm.views import (
@ -205,6 +206,12 @@ urlpatterns_materia = [
name='materia_remover_assinatura'), name='materia_remover_assinatura'),
url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3, url(r'^materia/assinatura/detectar-a3/$', detectar_aplicacao_a3,
name='detectar_aplicacao_a3'), name='detectar_aplicacao_a3'),
url(r'^materia/assinatura/api-status/$', assinatura_api_status,
name='assinatura_api_status'),
url(r'^materia/(?P<pk>\d+)/assinatura/pagina-preview/$', materia_pagina_assinatura_png,
name='materia_pagina_assinatura_png'),
url(r'^materia/documentoacessorio/(?P<pk>\d+)/assinatura/pagina-preview/$', docacessorio_pagina_assinatura_png,
name='docacessorio_pagina_assinatura_png'),
# Verificação pública de autenticidade (sem login) # Verificação pública de autenticidade (sem login)
url(r'^materia/(?P<pk>\d+)/verificar/$', materia_verificar_documento, url(r'^materia/(?P<pk>\d+)/verificar/$', materia_verificar_documento,

1764
sapl/materia/views_assinatura.py

File diff suppressed because it is too large

7
sapl/settings.py

@ -318,6 +318,13 @@ ONLYOFFICE_JWT_ENABLED = config('ONLYOFFICE_JWT_ENABLED', cast=bool, default=Fal
# consegue acessar o SAPL pela mesma URL que o navegador) # consegue acessar o SAPL pela mesma URL que o navegador)
SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='') SAPL_INTERNAL_URL = config('SAPL_INTERNAL_URL', default='')
# Microserviço de Assinatura Digital
# Quando ASSINATURA_API_URL estiver configurado, o SAPL delega a assinatura
# dos PDFs ao microserviço em vez de usar pyhanko localmente.
ASSINATURA_API_URL = config('ASSINATURA_API_URL', default='')
ASSINATURA_API_KEY = config('ASSINATURA_API_KEY', default='')
ASSINATURA_API_TIMEOUT = config('ASSINATURA_API_TIMEOUT', cast=int, default=120)
# Integração externa de matérias da sessão # Integração externa de matérias da sessão
SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='') SESSAO_MATERIAS_API_URL = config('SESSAO_MATERIAS_API_URL', default='')
SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='') SESSAO_MATERIAS_API_KEY = config('SESSAO_MATERIAS_API_KEY', default='')

49
sapl/templates/materia/assinatura_modal.html

@ -139,6 +139,8 @@
<i class="fa fa-exclamation-triangle"></i> <i class="fa fa-exclamation-triangle"></i>
{% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %} {% trans "Atenção: A assinatura digital tem validade jurídica. Certifique-se de que o documento está correto antes de assinar." %}
</div> </div>
</form> </form>
</div> </div>
@ -531,12 +533,32 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
this.mostrarStatus('processando'); this.mostrarStatus('processando'); this.atualizarProgresso(5, 'Enviando certificado…');
this.atualizarProgresso(10, 'Enviando certificado...'); var formData = new FormData(form);
var formData = new FormData(); // Animação de progresso enquanto o servidor processa
formData.append('certificado', certificado); var progressoTimer = null;
formData.append('senha', senha); var progressoAtual = 5;
var fases = [
{ ate: 30, label: 'Validando certificado…', demora: 800 },
{ ate: 55, label: 'Gerando página de autenticação…', demora: 600 },
{ ate: 75, label: 'Assinando documento…', demora: 500 },
{ ate: 88, label: 'Enviando ao microserviço…', demora: 400 },
{ ate: 95, label: 'Aguardando resposta…', demora: 300 },
];
var faseIdx = 0;
function avancarProgresso() {
if (faseIdx >= fases.length) return;
var fase = fases[faseIdx];
if (progressoAtual < fase.ate) {
progressoAtual = Math.min(progressoAtual + 2, fase.ate);
self.atualizarProgresso(progressoAtual, fase.label);
} else {
faseIdx++;
}
progressoTimer = setTimeout(avancarProgresso, fase.demora);
}
progressoTimer = setTimeout(avancarProgresso, 600);
fetch(this.urlPrefix + '/assinar/a1/', { fetch(this.urlPrefix + '/assinar/a1/', {
method: 'POST', method: 'POST',
@ -546,19 +568,26 @@ document.addEventListener('DOMContentLoaded', function() {
} }
}) })
.then(function(response) { .then(function(response) {
self.atualizarProgresso(60, 'Processando assinatura...'); clearTimeout(progressoTimer);
self.atualizarProgresso(97, 'Processando resposta…');
if (!response.ok) {
return response.json().then(function(d) {
throw new Error(d.error || ('Erro HTTP ' + response.status));
});
}
return response.json(); return response.json();
}) })
.then(function(data) { .then(function(data) {
self.atualizarProgresso(100, 'Finalizando...'); clearTimeout(progressoTimer);
self.atualizarProgresso(100, 'Concluído.');
if (data.success) { if (data.success) {
self.mostrarSucesso(data); self.mostrarSucesso(data);
} else { } else {
self.mostrarErro(data.error); self.mostrarErro(data.error || 'Erro desconhecido ao assinar.');
} }
}) })
.catch(function(error) { .catch(function(error) {
clearTimeout(progressoTimer);
self.mostrarErro('Erro de comunicação: ' + error.message); self.mostrarErro('Erro de comunicação: ' + error.message);
}); });
}, },
@ -652,7 +681,6 @@ document.addEventListener('DOMContentLoaded', function() {
window.AssinaturaModal = AssinaturaModal; window.AssinaturaModal = AssinaturaModal;
}); });
</script> </script>
<style> <style>
.tipo-certificado-card { .tipo-certificado-card {
cursor: pointer; cursor: pointer;
@ -716,4 +744,5 @@ document.addEventListener('DOMContentLoaded', function() {
font-size: 1.05rem; font-size: 1.05rem;
padding: 0.6rem 1.5rem; padding: 0.6rem 1.5rem;
} }
</style> </style>

28
sapl/templates/materia/documentoacessorio_list.html

@ -487,7 +487,28 @@
document.getElementById('doc-lote-btn-assinar').style.display = 'none'; document.getElementById('doc-lote-btn-assinar').style.display = 'none';
document.getElementById('doc-lote-btn-fechar').disabled = true; document.getElementById('doc-lote-btn-fechar').disabled = true;
setProgresso(5, 'Enviando certificado e iniciando assinatura…'); // Animação de progresso proporcional ao número de documentos
var nDocs = idsSel.length;
var fases = [
{ ate: 10, label: 'Enviando certificado e iniciando assinatura…', ms: 700 },
{ ate: 25, label: 'Validando certificado e gerando PDFs…', ms: Math.min(600, 200 + nDocs * 30) },
{ ate: 50, label: 'Preparando ' + nDocs + ' documento(s) para assinatura…', ms: Math.min(800, 200 + nDocs * 50) },
{ ate: 70, label: 'Enviando ao microserviço de assinatura…', ms: Math.min(1000, 300 + nDocs * 60) },
{ ate: 85, label: 'Aguardando resposta do servidor…', ms: Math.min(800, 300 + nDocs * 40) },
{ ate: 93, label: 'Salvando documentos assinados…', ms: 500 },
{ ate: 97, label: 'Finalizando…', ms: 300 },
];
var faseIdx = 0, progrAtual = 0, progrTimer = null;
function avancarProgresso() {
if (faseIdx >= fases.length) return;
var fase = fases[faseIdx];
if (progrAtual < fase.ate) {
progrAtual = Math.min(progrAtual + 1, fase.ate);
setProgresso(progrAtual, fase.label);
} else { faseIdx++; }
progrTimer = setTimeout(avancarProgresso, fases[Math.min(faseIdx, fases.length-1)].ms / (fase.ate - (faseIdx > 0 ? fases[faseIdx-1].ate : 0)));
}
progrTimer = setTimeout(avancarProgresso, 400);
fetch(URL_LOTE, { fetch(URL_LOTE, {
method: 'POST', method: 'POST',
@ -495,16 +516,19 @@
headers: { 'X-CSRFToken': getCSRF() } headers: { 'X-CSRFToken': getCSRF() }
}) })
.then(function(r){ .then(function(r){
setProgresso(90, 'Processando resposta…'); clearTimeout(progrTimer);
setProgresso(98, 'Processando resposta…');
if (!r.ok) return r.json().then(function(d){ throw new Error(d.error || ('HTTP ' + r.status)); }); if (!r.ok) return r.json().then(function(d){ throw new Error(d.error || ('HTTP ' + r.status)); });
return r.json(); return r.json();
}) })
.then(function(data){ .then(function(data){
clearTimeout(progrTimer);
setProgresso(100, 'Concluído.'); setProgresso(100, 'Concluído.');
document.getElementById('doc-lote-btn-fechar').disabled = false; document.getElementById('doc-lote-btn-fechar').disabled = false;
mostrarResumo(data); mostrarResumo(data);
}) })
.catch(function(err){ .catch(function(err){
clearTimeout(progrTimer);
document.getElementById('doc-lote-btn-fechar').disabled = false; document.getElementById('doc-lote-btn-fechar').disabled = false;
setProgresso(100, 'Erro.'); setProgresso(100, 'Erro.');
mostrarErroFatal(err.message || String(err)); mostrarErroFatal(err.message || String(err));

28
sapl/templates/materia/materialegislativa_filter.html

@ -795,7 +795,28 @@
document.getElementById('lote-btn-assinar').style.display = 'none'; document.getElementById('lote-btn-assinar').style.display = 'none';
document.getElementById('lote-btn-fechar').disabled = true; document.getElementById('lote-btn-fechar').disabled = true;
setProgresso(5, 'Enviando certificado e iniciando assinatura…'); // Calcula fases de progresso em função do número de matérias selecionadas
var nDocs = idsSel.length;
var fases = [
{ ate: 10, label: 'Enviando certificado e iniciando assinatura…', ms: 700 },
{ ate: 25, label: 'Validando certificado e gerando PDFs…', ms: Math.min(600, 200 + nDocs * 30) },
{ ate: 50, label: 'Preparando ' + nDocs + ' documento(s) para assinatura…', ms: Math.min(800, 200 + nDocs * 50) },
{ ate: 70, label: 'Enviando ao microserviço de assinatura…', ms: Math.min(1000, 300 + nDocs * 60) },
{ ate: 85, label: 'Aguardando resposta do servidor…', ms: Math.min(800, 300 + nDocs * 40) },
{ ate: 93, label: 'Salvando documentos assinados…', ms: 500 },
{ ate: 97, label: 'Finalizando…', ms: 300 },
];
var faseIdx = 0, progrAtual = 0, progrTimer = null;
function avancarProgresso() {
if (faseIdx >= fases.length) return;
var fase = fases[faseIdx];
if (progrAtual < fase.ate) {
progrAtual = Math.min(progrAtual + 1, fase.ate);
setProgresso(progrAtual, fase.label);
} else { faseIdx++; }
progrTimer = setTimeout(avancarProgresso, fases[Math.min(faseIdx, fases.length-1)].ms / (fase.ate - (faseIdx > 0 ? fases[faseIdx-1].ate : 0)));
}
progrTimer = setTimeout(avancarProgresso, 400);
fetch(URL_LOTE, { fetch(URL_LOTE, {
method: 'POST', method: 'POST',
@ -803,18 +824,21 @@
headers: { 'X-CSRFToken': getCSRF() } headers: { 'X-CSRFToken': getCSRF() }
}) })
.then(function (r) { .then(function (r) {
setProgresso(90, 'Processando resposta…'); clearTimeout(progrTimer);
setProgresso(98, 'Processando resposta…');
if (!r.ok) { if (!r.ok) {
return r.json().then(function(d){ throw new Error(d.error || ('HTTP ' + r.status)); }); return r.json().then(function(d){ throw new Error(d.error || ('HTTP ' + r.status)); });
} }
return r.json(); return r.json();
}) })
.then(function (data) { .then(function (data) {
clearTimeout(progrTimer);
setProgresso(100, 'Concluído.'); setProgresso(100, 'Concluído.');
document.getElementById('lote-btn-fechar').disabled = false; document.getElementById('lote-btn-fechar').disabled = false;
mostrarResumo(data); mostrarResumo(data);
}) })
.catch(function (err) { .catch(function (err) {
clearTimeout(progrTimer);
document.getElementById('lote-btn-fechar').disabled = false; document.getElementById('lote-btn-fechar').disabled = false;
setProgresso(100, 'Erro.'); setProgresso(100, 'Erro.');
mostrarErroFatal(err.message || String(err)); mostrarErroFatal(err.message || String(err));

Loading…
Cancel
Save