Files
clientflow_backend/app/admin_ui/pages/reconciliation.py

753 lines
46 KiB
Python

"""Operational reconciliation and external intake UI.
This page is a staging area for information found outside ClientFlow. It does
not replace Operations; it prepares loose Jasmin/Odoo/payment/manual evidence so
an operator can link, create or ignore it deliberately.
"""
from __future__ import annotations
import logging
from typing import Optional
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse, RedirectResponse
from app.admin_ui.components import esc, fmt_dt, kpi_card, money, status_chip
from app.admin_ui.layout import layout
from app.fiscal_enrichment_service import enrich_open_opportunities, fiscal_enrichment_summary
from app.external_reconciliation_sync import (
sync_all_external_reconciliation_candidates,
sync_jasmin_reconciliation_candidates,
sync_odoo_reconciliation_candidates,
sync_packlink_reconciliation_candidates,
)
from app.reconciliation_decision_service import (
classify_reconciliation_item,
classify_reconciliation_process,
reconciliation_decision_summary,
sort_items_for_operator,
)
from app.reconciliation_service import (
cleanup_reconciliation_outside_window,
create_external_request,
create_opportunity_from_reconciliation,
create_payment_proof,
link_reconciliation_to_opportunity,
list_reconciliation_items,
list_reconciliation_process_candidates,
create_opportunity_from_reconciliation_process,
link_reconciliation_process_to_opportunity,
reconciliation_summary,
recent_window_start,
reset_generated_reconciliation_items,
set_reconciliation_status,
sync_local_documents_without_opportunity,
upsert_reconciliation_item,
)
logger = logging.getLogger(__name__)
router = APIRouter()
TYPE_LABELS = {
"jasmin_quotation": "Orçamento Jasmin",
"jasmin_proforma": "Orçamento Jasmin legado",
"jasmin_invoice": "Fatura Jasmin",
"odoo_sale_order": "Venda Odoo",
"payment_proof": "Comprovativo",
"packlink_shipment": "Envio Packlink",
"manual_request": "Pedido externo",
"external_record": "Registo externo",
}
def _type_label(value: str | None) -> str:
return TYPE_LABELS.get(str(value or ""), str(value or ""))
def _safe_days(value: object, default: int = 3) -> int:
try:
return min(max(int(value or default), 1), 90)
except Exception:
return default
def _filter_url(status: str = "open", external_type: str = "all", days: int = 3) -> str:
return f"/reconciliation?status={esc(status)}&external_type={esc(external_type)}&days={int(days or 3)}"
def _operation_label(value: str | None) -> str:
raw = str(value or "")
if not raw:
return ""
labels = {
"odoo_sale_order": "Venda Odoo",
"jasmin_quotation": "Orçamento Jasmin",
"jasmin_proforma": "Orçamento Jasmin legado",
"jasmin_invoice": "Fatura Jasmin",
"document": "Documento",
}
if ":" in raw:
key, ref = raw.split(":", 1)
return f"{labels.get(key, key)} {ref}"
return raw
def _render_item_rows(items: list[dict]) -> str:
rows = ""
for item in items:
item_id = str(item.get("id") or "")
opportunity_link = ""
if item.get("opportunity_id"):
opportunity_label = item.get("opportunity_title") or "Ver oportunidade"
opportunity_link = f'<a href="/opportunities/{esc(item.get("opportunity_id"))}">{esc(opportunity_label)}</a>'
amount = ""
if item.get("amount") is not None:
amount = money(item.get("amount"), item.get("currency") or "EUR")
actions = ""
if str(item.get("status") or "") in {"open", "needs_review"}:
suggestions = item.get("operation_suggestions") or []
suggestion_html = ""
if suggestions:
suggestion_cards = ""
for suggestion in suggestions[:2]:
suggested_opp_id = str(suggestion.get("opportunity_id") or "")
suggested_title = suggestion.get("opportunity_title") or "Oportunidade aberta"
suggested_customer = suggestion.get("customer_name") or ""
suggested_action = suggestion.get("action") or suggestion.get("action_code") or "operação aberta"
suggested_reason = suggestion.get("reason") or "possível correspondência"
suggestion_cards += f"""
<form method="post" action="/reconciliation/{esc(item_id)}/link" hx-confirm="Ligar este item à operação/oportunidade sugerida?" class="cf-reconcile-suggestion">
<input type="hidden" name="opportunity_id" value="{esc(suggested_opp_id)}">
<div class="small text-secondary">Sugestão encontrada em Operations</div>
<div class="fw-semibold text-break">{esc(suggested_title)}</div>
<div class="small text-secondary text-break">{esc(suggested_customer)}</div>
<div class="small text-secondary text-break">{esc(suggested_action)} · {esc(suggested_reason)}</div>
<button class="btn btn-sm btn-primary mt-2" type="submit">Ligar a esta operação</button>
</form>
"""
suggestion_html = f'<div class="d-grid gap-2 mb-2">{suggestion_cards}</div>'
create_button_class = "btn-outline-primary" if suggestions else "btn-primary"
search_query = item.get("customer_tax_id") or item.get("customer_email") or item.get("customer_name") or item.get("document_number") or ""
search_href = f"/opportunities?q={esc(str(search_query))}" if search_query else "/opportunities"
manual_link = f"""
<details class="cf-reconcile-manual-link small text-secondary mt-1">
<summary>Escolher outra oportunidade</summary>
<form class="d-flex gap-1 mt-2" method="post" action="/reconciliation/{esc(item_id)}/link" hx-confirm="Ligar este item à oportunidade indicada?">
<input class="form-control form-control-sm" name="opportunity_id" placeholder="ID oportunidade" style="max-width:170px">
<button class="btn btn-sm btn-outline-primary" type="submit">Ligar</button>
</form>
</details>
"""
actions = f"""
<div class="d-grid gap-2 justify-content-end">
{suggestion_html}
<div class="d-flex flex-wrap justify-content-end gap-2">
<a class="btn btn-sm btn-outline-secondary" href="{search_href}">Pesquisar oportunidade</a>
<form method="post" action="/reconciliation/{esc(item_id)}/create-opportunity" hx-confirm="Criar nova oportunidade a partir deste item? Use isto apenas se não houver operação aberta correspondente.">
<button class="btn btn-sm {create_button_class}" type="submit">Criar oportunidade</button>
</form>
<form method="post" action="/reconciliation/{esc(item_id)}/ignore" hx-confirm="Ignorar este item de reconciliação?">
<button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button>
</form>
<form method="post" action="/reconciliation/{esc(item_id)}/needs-review" hx-confirm="Enviar este item para revisão manual?">
<button class="btn btn-sm btn-outline-warning" type="submit">Rever</button>
</form>
<form method="post" action="/reconciliation/{esc(item_id)}/historical" hx-confirm="Marcar este item como histórico sem ação operacional?">
<button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button>
</form>
</div>
{manual_link}
</div>
"""
else:
actions = '<span class="text-secondary small">Sem ações pendentes</span>'
decision = classify_reconciliation_item(item)
decision_chip = f'<span class="cf-chip {esc(decision.get("chip_class") or "cf-chip-gray")}">{esc(decision.get("label") or "Decisão")}</span>'
rows += f"""
<tr>
<td>
<div class="fw-bold">{esc(item.get('title'))}</div>
<div class="small text-secondary text-break">{esc(item.get('description') or '')}</div>
<div class="small text-secondary mt-1">{esc(item.get('source_system'))} · {esc(item.get('external_id') or 'sem external_id')}</div>
</td>
<td><span class="cf-chip cf-chip-blue">{esc(_type_label(item.get('external_type')))}</span><div class="small mt-1">{esc(item.get('document_number') or '')}</div></td>
<td>{status_chip(item.get('status'))}<div class="small text-secondary mt-1">{esc(item.get('priority') or 'normal')}</div><div class="mt-1">{decision_chip}</div><div class="small text-secondary mt-1">{esc(decision.get('primary_decision') or '')}</div></td>
<td><div>{esc(item.get('customer_name') or item.get('linked_customer_name') or '')}</div><div class="small text-secondary">{esc(item.get('customer_email') or '')}</div>{f'<div class="small text-secondary">NIF {esc(item.get("customer_tax_id"))}</div>' if item.get('customer_tax_id') else ''}</td>
<td>{amount}<div class="small text-secondary">{esc(fmt_dt(item.get('document_date')))}</div></td>
<td>{opportunity_link}</td>
<td class="text-end">{actions}</td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="7" class="text-center text-secondary py-5">Sem itens de reconciliação para este filtro.</td></tr>'
return rows
def _render_process_candidates(candidates: list[dict], *, days: int = 3) -> str:
"""Render grouped timeline reconstruction proposals.
These cards are intentionally more useful than isolated rows: they show the
probable sequence found across Jasmin/Odoo/Packlink/payment evidence and let
the operator create or link one process deliberately.
"""
if not candidates:
return """
<section class="card cf-card mb-4">
<div class="card-body p-4 text-secondary">
<h2 class="cf-section-title mb-2">Processos candidatos</h2>
<div>Sem processos reconstruíveis na janela ativa. Itens isolados continuam disponíveis abaixo.</div>
</div>
</section>
"""
cards = ""
for candidate in candidates[:8]:
item_ids = ",".join(str(x) for x in candidate.get("item_ids") or [])
tax_line = f'<div class="small text-secondary">NIF {esc(candidate.get("customer_tax_id"))}</div>' if candidate.get("customer_tax_id") else ""
email_line = f'<div class="small text-secondary text-break">{esc(candidate.get("customer_email"))}</div>' if candidate.get("customer_email") else ""
amount = ""
if candidate.get("amount") is not None:
amount = f'<span class="cf-chip cf-chip-gray">{money(candidate.get("amount"), candidate.get("currency") or "EUR")}</span>'
identity_line = f'<div class="small text-secondary">Cliente fiscal: {esc(candidate.get("identity_reason") or candidate.get("match_key") or "")}</div>'
operation_label = _operation_label(candidate.get("operation_key"))
operation_line = f'<div class="small text-secondary">Compra/processo: {esc(operation_label)}</div>' if operation_label else ""
review_status = str(candidate.get("review_status") or "needs_review")
review_label = {"ready": "pronto", "needs_review": "rever", "conflict": "conflito"}.get(review_status, review_status)
review_class = "green" if review_status == "ready" else "orange" if review_status == "needs_review" else "red"
review_chip = f'<span class="cf-chip cf-chip-{review_class}">{esc(review_label)}</span>'
decision = classify_reconciliation_process(candidate)
decision_chip = f'<span class="cf-chip {esc(decision.get("chip_class") or "cf-chip-gray")}">{esc(decision.get("label") or "Decisão")}</span>'
reasons = "".join(f'<li>{esc(reason)}</li>' for reason in (candidate.get("reasons") or [])[:5])
risks = "".join(f'<li>{esc(risk)}</li>' for risk in (candidate.get("risks") or [])[:5])
explanation_html = (
'<div class="cf-reconcile-explain">'
f'<div><strong>Motivos</strong><ul>{reasons or "<li>candidato reconstruído por identidade fiscal/processo</li>"}</ul></div>'
f'<div><strong>Riscos</strong><ul>{risks or "<li>sem riscos relevantes detetados</li>"}</ul></div>'
'</div>'
)
timeline = ""
for step in candidate.get("timeline") or []:
timeline += f"""
<div class="cf-reconcile-timeline-step">
<div class="small text-secondary">{esc(fmt_dt(step.get('document_date')))}</div>
<div><strong>{esc(step.get('label'))}</strong></div>
<div class="small text-secondary text-break">{esc(step.get('document_number') or step.get('title') or '')}</div>
</div>
"""
suggestion_html = ""
suggestions = candidate.get("suggestions") or []
if suggestions:
suggestion = suggestions[0]
suggestion_html = f"""
<form method="post" action="/reconciliation/processes/link" hx-confirm="Ligar todos os itens deste processo à oportunidade sugerida?" class="cf-reconcile-suggestion">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<input type="hidden" name="opportunity_id" value="{esc(suggestion.get('opportunity_id') or '')}">
<div class="small text-secondary">Sugestão de oportunidade existente</div>
<div class="fw-semibold text-break">{esc(suggestion.get('opportunity_title') or 'Oportunidade aberta')}</div>
<div class="small text-secondary text-break">{esc(suggestion.get('customer_name') or '')} · {esc(suggestion.get('reason') or 'possível correspondência')}</div>
<button class="btn btn-sm btn-primary mt-2" type="submit">Ligar processo à sugestão</button>
</form>
"""
cards += f"""
<article class="cf-reconcile-process-card">
<div class="d-flex justify-content-between gap-3 align-items-start flex-wrap">
<div>
<h3 class="h6 mb-1">{esc(candidate.get('customer_name') or 'Processo externo')}</h3>
{email_line}{tax_line}
</div>
<div class="d-flex gap-2 flex-wrap justify-content-end">
<span class="cf-chip cf-chip-blue">{len(candidate.get('items') or [])} evidência(s)</span>
<span class="cf-chip cf-chip-orange">confiança {esc(candidate.get('confidence') or 'média')}</span>
{review_chip}
{decision_chip}
{amount}
</div>
</div>
{identity_line}{operation_line}
<div class="cf-reconcile-decision-note"><strong>{esc(decision.get('primary_decision') or 'Decisão pendente')}</strong><span>{esc(decision.get('description') or '')}</span></div>
{explanation_html}
<div class="cf-reconcile-process-state">
<div><span>Estado sugerido</span><strong>{esc(candidate.get('suggested_stage') or 'REVIEW')}</strong></div>
<div><span>Próxima ação</span><strong>{esc(candidate.get('suggested_action') or 'REVIEW_MANUALLY')}</strong></div>
</div>
<div class="cf-reconcile-timeline">{timeline}</div>
<div class="d-flex flex-wrap gap-2 align-items-start">
{suggestion_html}
<form method="post" action="/reconciliation/processes/create-opportunity" hx-confirm="Criar uma oportunidade reconstruída com esta timeline?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-primary" type="submit">Criar oportunidade reconstruída</button>
</form>
<form method="post" action="/reconciliation/processes/needs-review" hx-confirm="Enviar este processo para revisão manual?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-warning" type="submit">Rever</button>
</form>
<form method="post" action="/reconciliation/processes/historical" hx-confirm="Marcar este processo como histórico sem ação operacional?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-secondary" type="submit">Histórico</button>
</form>
<form method="post" action="/reconciliation/processes/ignore" hx-confirm="Ignorar este processo? Use apenas quando pertence a histórico externo ou não deve criar ação operacional.">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<button class="btn btn-sm btn-outline-secondary" type="submit">Ignorar</button>
</form>
<details class="small text-secondary cf-reconcile-manual-link">
<summary>Ligar a outra oportunidade</summary>
<form method="post" action="/reconciliation/processes/link" class="d-flex gap-1 mt-2" hx-confirm="Ligar todos os itens deste processo à oportunidade indicada?">
<input type="hidden" name="item_ids" value="{esc(item_ids)}">
<input class="form-control form-control-sm" name="opportunity_id" placeholder="ID oportunidade" style="max-width:180px">
<button class="btn btn-sm btn-outline-primary" type="submit">Ligar</button>
</form>
</details>
</div>
</article>
"""
return f"""
<section class="card cf-card mb-4">
<div class="card-body p-3 border-bottom">
<h2 class="cf-section-title mb-1">Processos candidatos</h2>
<div class="small text-secondary">O sistema agrupa primeiro por cliente fiscal e depois separa por compra/processo antes de criar ou ligar a oportunidade.</div>
</div>
<div class="card-body p-3 d-grid gap-3">{cards}</div>
</section>
"""
def render_reconciliation_decision_board(items: list[dict], candidates: list[dict], *, status: str, external_type: str, days: int) -> str:
summary = reconciliation_decision_summary(items, candidates)
cards = [
("actionable", "Ação recomendada", "Ligar/criar com evidência suficiente", "open"),
("review", "Requer revisão", "Conflitos ou baixa confiança", "needs_review"),
("historical", "Histórico", "Sem ação operacional", "historical"),
("ignored", "Ignorados", "Fora da fila", "ignored"),
("resolved", "Resolvidos", "Ligados ou fechados", "linked"),
]
html = ""
for key, label, description, target_status in cards:
active = " active" if ((key == "actionable" and status == "open") or status == target_status) else ""
href = _filter_url(target_status, external_type, days)
html += f'''
<a class="cf-decision-card{active}" href="{href}">
<span>{esc(label)}</span>
<strong>{int(summary.get(key, 0))}</strong>
<small>{esc(description)}</small>
</a>
'''
return f'''
<section class="cf-reconcile-decision-grid mb-3" aria-label="Resumo decisional de reconciliação">
{html}
</section>
'''
def render_reconciliation_table(items: list[dict]) -> str:
return f"""
<div class="cf-table-wrap border-0 rounded-0">
<table class="table cf-table align-middle">
<thead><tr><th>Item</th><th>Tipo</th><th>Estado</th><th>Cliente</th><th>Valor/data</th><th>Oportunidade</th><th class="text-end">Ações</th></tr></thead>
<tbody>{_render_item_rows(items)}</tbody>
</table>
</div>
"""
@router.get("/reconciliation", response_class=HTMLResponse)
@router.get("/reconciliacao", response_class=HTMLResponse)
async def reconciliation_page(status: Optional[str] = "open", external_type: Optional[str] = "all", notice: Optional[str] = None, days: Optional[int] = 3):
status = status or "open"
external_type = external_type or "all"
item_type = None if external_type == "all" else external_type
recent_days = min(max(int(days or 3), 1), 90)
items = sort_items_for_operator(list_reconciliation_items(status=status, external_type=item_type, limit=100, days=recent_days))
process_candidates = list_reconciliation_process_candidates(status="open", days=recent_days, limit=8)
summary = reconciliation_summary(days=recent_days)
enrichment_summary = fiscal_enrichment_summary()
notice_html = f'<div class="alert alert-info border-0">{esc(notice)}</div>' if notice else ""
status_filters = "".join(
f'<a class="btn btn-sm {"btn-primary" if status == value else "btn-outline-secondary"}" href="{_filter_url(value, external_type, recent_days)}">{esc(label)}</a>'
for value, label in [("open", "Abertos"), ("needs_review", "Revisão"), ("conflict", "Conflitos"), ("historical", "Histórico"), ("linked", "Ligados"), ("resolved", "Resolvidos"), ("ignored", "Ignorados"), ("all", "Todos")]
)
type_filters = "".join(
f'<a class="btn btn-sm {"btn-primary" if external_type == value else "btn-outline-secondary"}" href="{_filter_url(status, value, recent_days)}">{esc(label)}</a>'
for value, label in [("all", "Todos"), ("jasmin_quotation", "Orçamentos"), ("jasmin_invoice", "Faturas"), ("payment_proof", "Comprovativos"), ("odoo_sale_order", "Odoo"), ("packlink_shipment", "Packlink"), ("manual_request", "Externos")]
)
body = f"""
{notice_html}
<section class="alert alert-primary border-0 shadow-sm">
<strong>Reconciliação = informação solta para organizar.</strong>
O sistema cria candidatos por cliente fiscal e por compra/processo; o operador decide ligar, criar oportunidade ou ignorar. Janela ativa: últimos {recent_days} dias operacionais, desde {esc(recent_window_start(recent_days))}. Comprovativo recebido não confirma pagamento.
</section>
<section class="cf-kpi-grid">
{kpi_card('Itens abertos', summary.get('open', 0), '/reconciliation', 'pendências para ligar/criar/ignorar', 'bi-diagram-3')}
{kpi_card('Conflitos', summary.get('conflict', 0), '/reconciliation?status=conflict', 'cliente/processo com risco', 'bi-exclamation-triangle')}
{kpi_card('Histórico', summary.get('historical', 0), '/reconciliation?status=historical', 'fora da operação diária', 'bi-archive')}
{kpi_card('Resolvidos', summary.get('resolved', 0), '/reconciliation?status=linked', 'ligados ou fechados', 'bi-check2-circle')}
{kpi_card('Sugestões fiscais', enrichment_summary.get('pending_suggestions', 0), '/opportunities?scope=blocked', 'pendentes antes da reconciliação', 'bi-person-vcard')}
</section>
{render_reconciliation_decision_board(items, process_candidates, status=status, external_type=external_type, days=recent_days)}
<section class="card cf-card mb-3"><div class="card-body p-3 d-grid gap-3">
<div><strong class="me-2">Estado:</strong><span class="d-inline-flex flex-wrap gap-2">{status_filters}</span></div>
<div><strong class="me-2">Tipo:</strong><span class="d-inline-flex flex-wrap gap-2">{type_filters}</span></div>
<div><strong class="me-2">Janela:</strong><span class="d-inline-flex flex-wrap gap-2">
{''.join(f'<a class="btn btn-sm {"btn-primary" if recent_days == value else "btn-outline-secondary"}" href="/reconciliation?status={esc(status)}&external_type={esc(external_type)}&days={value}">{label}</a>' for value, label in [(1, 'Hoje'), (3, '3 dias'), (7, '7 dias'), (30, '30 dias')])}
</span></div>
<div class="d-flex flex-wrap gap-2 align-items-center">
<form class="d-inline-block" method="post" action="/reconciliation/sync-local-documents" hx-confirm="Procurar documentos locais sem oportunidade?">
<button class="btn btn-sm btn-outline-primary" type="submit">Procurar documentos locais</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-jasmin" hx-confirm="Consultar API Jasmin e criar candidatos de reconciliação?">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Jasmin</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-odoo" hx-confirm="Consultar API Odoo e criar candidatos de reconciliação?">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Odoo</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-packlink" hx-confirm="Consultar API Packlink e criar candidatos de reconciliação?">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-primary" type="submit">Sincronizar Packlink</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/enrich-fiscal" hx-confirm="Enriquecer oportunidades abertas sem cliente fiscal antes da reconciliação? Auto-associa apenas matches muito fortes.">
<button class="btn btn-sm btn-outline-success" type="submit">Enriquecer oportunidades</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/sync-external" hx-confirm="Consultar APIs externas ativas na janela ativa? A sequência é: enriquecimento fiscal → clientes fiscais → documentos Jasmin → vendas Odoo → envios Packlink. Não cria oportunidades automaticamente.">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-primary" type="submit">Sincronizar APIs externas</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/rebuild" hx-confirm="Reconstruir a reconciliação da janela ativa? Vai apagar apenas candidatos gerados por Jasmin/Odoo/Packlink sem oportunidade ligada, criar backup e correr a sequência enriquecimento fiscal → clientes fiscais → Jasmin → Odoo → faturas/envios. Não apaga clientes, oportunidades nem documentos reais.">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-danger" type="submit">Apagar e correr novamente</button>
</form>
<form class="d-inline-block" method="post" action="/reconciliation/cleanup-window" hx-confirm="Limpar da fila aberta os candidatos fora da janela ativa? Não apaga documentos externos.">
<input type="hidden" name="days" value="{recent_days}"><button class="btn btn-sm btn-outline-danger" type="submit">Limpar fora dos 3 dias/janela</button>
</form>
</div>
</div></section>
{_render_process_candidates(process_candidates, days=recent_days)}
<section class="card cf-card mb-4">
<div class="card-body p-0">
<div class="p-3 border-bottom"><h2 class="cf-section-title mb-1">Itens soltos</h2><div class="small text-secondary">Itens individuais que ainda precisam de decisão ou que não formaram um processo candidato.</div></div>
{render_reconciliation_table(items)}
</div>
</section>
<div class="row g-3">
<div class="col-xl-6">
<section class="card cf-card h-100"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Registar pedido externo</h2>
<form method="post" action="/reconciliation/manual-request" class="d-grid gap-3">
<div class="row g-2"><div class="col-md-4"><label class="form-label small fw-bold">Origem</label><select class="form-select" name="source_channel"><option>WhatsApp</option><option>Telefone</option><option>Email direto</option><option>Presencial</option><option>Outro</option></select></div><div class="col-md-8"><label class="form-label small fw-bold">Nome/contacto</label><input class="form-control" name="customer_name" required></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Email</label><input class="form-control" name="customer_email" type="email" autocomplete="off"></div><div class="col-md-6"><label class="form-label small fw-bold">Telefone</label><input class="form-control" name="customer_phone" type="tel" autocomplete="off"></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Produto/interesse</label><input class="form-control" name="product_interest" placeholder="Carregador EV, Cabo..."></div><div class="col-md-6"><label class="form-label small fw-bold">Próxima ação</label><select class="form-select" name="action_code"><option value="SEND_QUOTE">Preparar orçamento</option><option value="SEND_INFO">Preparar resposta</option><option value="SEND_PROFORMA">Enviar orçamento para pagamento</option><option value="SEND_INVOICE">Emitir fatura</option><option value="REVIEW_MANUALLY">Rever manualmente</option></select></div></div>
<div><label class="form-label small fw-bold">Mensagem/pedido</label><textarea class="form-control" rows="3" name="request_text" placeholder="Colar texto do WhatsApp, telefone ou email direto..."></textarea></div>
<div><button class="btn btn-primary" type="submit">Guardar e criar oportunidade</button></div>
</form>
</div></section>
</div>
<div class="col-xl-6">
<section class="card cf-card h-100"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Adicionar comprovativo</h2>
<form method="post" action="/reconciliation/payment-proof" class="d-grid gap-3">
<div class="alert alert-warning border-0 py-2 mb-0"><strong>Regra:</strong> comprovativo recebido abre validação financeira; não confirma pagamento.</div>
<div class="row g-2"><div class="col-md-7"><label class="form-label small fw-bold">ID oportunidade, se conhecido</label><input class="form-control" name="opportunity_id" placeholder="opcional"></div><div class="col-md-5"><label class="form-label small fw-bold">Origem</label><select class="form-select" name="source_system"><option>WhatsApp</option><option>Chatwoot</option><option>Email</option><option>Manual</option></select></div></div>
<div class="row g-2"><div class="col-md-6"><label class="form-label small fw-bold">Valor indicado</label><input class="form-control" name="amount" placeholder="190,00"></div><div class="col-md-6"><label class="form-label small fw-bold">Ficheiro/ref.</label><input class="form-control" name="filename" placeholder="comprovativo.jpg"></div></div>
<div><label class="form-label small fw-bold">Nota</label><textarea class="form-control" rows="3" name="note" placeholder="Referência, banco, MB Way, observações..."></textarea></div>
<div><button class="btn btn-primary" type="submit">Guardar comprovativo</button></div>
</form>
</div></section>
</div>
</div>
<section class="card cf-card mt-3"><div class="card-body p-4">
<h2 class="cf-section-title mb-3">Criar candidato manual de reconciliação</h2>
<form method="post" action="/reconciliation/item" class="row g-2 align-items-end">
<div class="col-md-2"><label class="form-label small fw-bold">Sistema</label><select class="form-select" name="source_system"><option>jasmin</option><option>odoo</option><option>manual</option></select></div>
<div class="col-md-2"><label class="form-label small fw-bold">Tipo</label><select class="form-select" name="external_type"><option value="jasmin_quotation">Orçamento</option><option value="jasmin_invoice">Fatura</option><option value="odoo_sale_order">Venda Odoo</option><option value="external_record">Outro</option></select></div>
<div class="col-md-2"><label class="form-label small fw-bold">Nº documento</label><input class="form-control" name="document_number"></div>
<div class="col-md-2"><label class="form-label small fw-bold">Cliente</label><input class="form-control" name="customer_name"></div>
<div class="col-md-2"><label class="form-label small fw-bold">Valor</label><input class="form-control" name="amount"></div>
<div class="col-md-2"><button class="btn btn-outline-primary w-100" type="submit">Criar candidato</button></div>
</form>
</div></section>
"""
return layout("Reconciliação", "Organizar documentos, vendas e comprovativos fora do ClientFlow", body, "reconciliation")
@router.post("/reconciliation/sync-local-documents")
async def reconciliation_sync_local_documents():
result = sync_local_documents_without_opportunity(limit=200)
notice = f"Documentos analisados: {result.get('seen', 0)} · candidatos criados/atualizados: {result.get('created_or_updated', 0)}"
return RedirectResponse(f"/reconciliation?notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-jasmin")
async def reconciliation_sync_jasmin(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = await sync_jasmin_reconciliation_candidates(limit=50, days=days)
notice = f"Jasmin últimos {result.get('days', days)} dias: analisados {result.get('seen', 0)} · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
if result.get("errors"):
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
if result.get("skipped"):
notice += " · " + str(result.get("skipped"))
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-odoo")
async def reconciliation_sync_odoo(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = sync_odoo_reconciliation_candidates(limit=50, days=days)
notice = f"Odoo últimos {result.get('days', days)} dias: analisadas {result.get('seen', 0)} vendas · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
if result.get("already_linked"):
notice += f" · já ligadas {result.get('already_linked', 0)}"
if result.get("resolved_existing"):
notice += f" · candidatos obsoletos resolvidos {result.get('resolved_existing', 0)}"
if result.get("link_conflicts"):
notice += f" · conflitos de ligação {result.get('link_conflicts', 0)}"
if result.get("skipped"):
notice += " · " + str(result.get("skipped"))
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-packlink")
async def reconciliation_sync_packlink(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = await sync_packlink_reconciliation_candidates(limit=50, days=days)
notice = f"Packlink últimos {result.get('days', days)} dias: analisados {result.get('seen', 0)} envios · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
if result.get("errors"):
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
if result.get("skipped"):
notice += " · " + str(result.get("skipped"))
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/enrich-fiscal")
async def reconciliation_enrich_fiscal(request: Request):
result = enrich_open_opportunities(limit=200, apply_safe=True, mode="operator_ui")
notice = (
f"Enriquecimento fiscal: analisadas {result.get('seen', 0)} oportunidades"
f" · sugestões {result.get('suggested', 0)}"
f" · auto-associadas {result.get('auto_applied', 0)}"
f" · ignoradas {result.get('skipped', 0)}"
)
if result.get("errors"):
notice += " · erros: " + "; ".join(str(x) for x in result.get("errors", [])[:2])
return RedirectResponse(f"/reconciliation?notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/sync-external")
async def reconciliation_sync_external(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = await sync_all_external_reconciliation_candidates(limit=50, days=days)
notice = (
f"APIs externas últimos {days} dias: enriquecidas {result.get('enrichment_auto_applied', 0)} oportunidades"
f" · sugestões fiscais {result.get('enrichment_suggested', 0)}"
f" · clientes fiscais analisados {result.get('customer_seen', 0)}"
f" · clientes criados/atualizados {result.get('customers_created_or_updated', 0)}"
f" · documentos/vendas analisados {result.get('seen', 0)}"
f" · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
)
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/rebuild")
async def reconciliation_rebuild(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
reset = reset_generated_reconciliation_items(days=days, limit=2000, actor="operator_ui", apply=True)
result = await sync_all_external_reconciliation_candidates(limit=200, days=days)
notice = (
f"Reconciliação reconstruída para {days} dias: apagados {reset.get('deleted', 0)} candidatos gerados"
f" · backup {reset.get('backup_table') or 'sem alterações'}"
f" · enriquecidas {result.get('enrichment_auto_applied', 0)} oportunidades"
f" · sugestões fiscais {result.get('enrichment_suggested', 0)}"
f" · clientes fiscais analisados {result.get('customer_seen', 0)}"
f" · clientes criados/atualizados {result.get('customers_created_or_updated', 0)}"
f" · documentos/vendas analisados {result.get('seen', 0)}"
f" · candidatos criados/atualizados {result.get('created_or_updated', 0)}"
)
errors = []
for source_result in result.get("results", []) or []:
errors.extend(str(err) for err in source_result.get("errors", []) or [])
if errors:
notice += " · erros: " + "; ".join(errors[:2])
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/cleanup-window")
async def reconciliation_cleanup_window(request: Request):
form = await request.form()
days = _safe_days(form.get("days"), 3)
result = cleanup_reconciliation_outside_window(days=days, limit=2000, actor="operator_ui")
notice = f"Limpeza aplicada: {result.get('ignored', 0)} itens fora dos últimos {result.get('days', days)} dias foram marcados como ignorados."
return RedirectResponse(f"/reconciliation?days={days}&notice={esc(notice)}", status_code=303)
@router.post("/reconciliation/manual-request")
async def reconciliation_manual_request(request: Request):
form = await request.form()
result = create_external_request(
source_channel=str(form.get("source_channel") or "manual"),
customer_name=str(form.get("customer_name") or ""),
customer_email=str(form.get("customer_email") or ""),
customer_phone=str(form.get("customer_phone") or ""),
product_interest=str(form.get("product_interest") or ""),
request_text=str(form.get("request_text") or ""),
action_code=str(form.get("action_code") or "SEND_QUOTE"),
)
opportunity_id = result.get("opportunity_id")
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Pedido%20externo%20registado", status_code=303)
@router.post("/reconciliation/payment-proof")
async def reconciliation_payment_proof(request: Request):
form = await request.form()
proof = create_payment_proof(
opportunity_id=str(form.get("opportunity_id") or "").strip() or None,
source_system=str(form.get("source_system") or "manual"),
source_ref=str(form.get("source_ref") or ""),
filename=str(form.get("filename") or ""),
amount=str(form.get("amount") or ""),
note=str(form.get("note") or ""),
)
if proof.get("task_id") and proof.get("opportunity_id"):
return RedirectResponse(f"/opportunities/{proof.get('opportunity_id')}?notice=Comprovativo%20registado%20para%20validação", status_code=303)
return RedirectResponse("/reconciliation?external_type=payment_proof&notice=Comprovativo%20por%20associar%20registado", status_code=303)
@router.post("/reconciliation/item")
async def reconciliation_create_item(request: Request):
form = await request.form()
source_system = str(form.get("source_system") or "manual")
external_type = str(form.get("external_type") or "external_record")
document_number = str(form.get("document_number") or "").strip()
customer_name = str(form.get("customer_name") or "").strip()
title = f"{_type_label(external_type)} sem ligação"
if document_number:
title += f" · {document_number}"
upsert_reconciliation_item(
source_system=source_system,
external_type=external_type,
external_id=document_number or None,
title=title,
description="Candidato criado manualmente para ligação/criação de oportunidade.",
customer_name=customer_name,
document_number=document_number,
amount=str(form.get("amount") or ""),
suggested_action="CONFIRM_PAYMENT" if external_type == "jasmin_invoice" else "SEND_PROFORMA",
)
return RedirectResponse("/reconciliation?notice=Candidato%20criado", status_code=303)
@router.post("/reconciliation/processes/create-opportunity")
async def reconciliation_create_process_opportunity(request: Request):
form = await request.form()
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
opportunity_id = create_opportunity_from_reconciliation_process(item_ids)
if opportunity_id:
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Oportunidade%20reconstruída%20a%20partir%20da%20reconciliação", status_code=303)
return RedirectResponse("/reconciliation?notice=Não%20foi%20possível%20reconstruir%20oportunidade", status_code=303)
@router.post("/reconciliation/processes/link")
async def reconciliation_link_process(request: Request):
form = await request.form()
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
opportunity_id = str(form.get("opportunity_id") or "").strip()
if opportunity_id and item_ids:
try:
count = link_reconciliation_process_to_opportunity(item_ids, opportunity_id)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={count}%20itens%20de%20processo%20ligados", status_code=303)
except Exception as exc: # pragma: no cover - production guard
logger.exception("failed to link reconstructed reconciliation process to opportunity %s", opportunity_id)
return RedirectResponse(
"/reconciliation?notice=Erro%20ao%20ligar%20processo.%20Ver%20journal%20do%20servi%C3%A7o.",
status_code=303,
)
return RedirectResponse("/reconciliation?notice=Indica%20o%20ID%20da%20oportunidade", status_code=303)
async def _set_reconciliation_process_status(request: Request, *, status: str, note: str, notice: str):
form = await request.form()
item_ids = [part.strip() for part in str(form.get("item_ids") or "").split(",") if part.strip()]
for item_id in item_ids:
set_reconciliation_status(item_id, status=status, note=note)
return RedirectResponse(f"/reconciliation?status={status}&notice={notice}", status_code=303)
@router.post("/reconciliation/processes/needs-review")
async def reconciliation_process_needs_review(request: Request):
return await _set_reconciliation_process_status(
request,
status="needs_review",
note="Processo enviado para revisão manual.",
notice="Processo%20enviado%20para%20revis%C3%A3o",
)
@router.post("/reconciliation/processes/historical")
async def reconciliation_process_historical(request: Request):
return await _set_reconciliation_process_status(
request,
status="historical",
note="Processo marcado como histórico sem ação operacional.",
notice="Processo%20marcado%20como%20hist%C3%B3rico",
)
@router.post("/reconciliation/processes/ignore")
async def reconciliation_process_ignore(request: Request):
return await _set_reconciliation_process_status(
request,
status="ignored",
note="Processo ignorado manualmente pelo operador.",
notice="Processo%20ignorado",
)
@router.post("/reconciliation/{item_id}/needs-review")
async def reconciliation_needs_review(item_id: str):
set_reconciliation_status(item_id, status="needs_review", note="Enviado para revisão manual.")
return RedirectResponse("/reconciliation?status=needs_review&notice=Item%20enviado%20para%20revis%C3%A3o", status_code=303)
@router.post("/reconciliation/{item_id}/ignore")
async def reconciliation_ignore(item_id: str):
set_reconciliation_status(item_id, status="ignored", note="Ignorado manualmente.")
return RedirectResponse("/reconciliation?notice=Item%20ignorado", status_code=303)
@router.post("/reconciliation/{item_id}/historical")
async def reconciliation_historical(item_id: str):
set_reconciliation_status(item_id, status="historical", note="Marcado como histórico sem ação operacional.")
return RedirectResponse("/reconciliation?status=historical&notice=Item%20marcado%20como%20hist%C3%B3rico", status_code=303)
@router.post("/reconciliation/{item_id}/create-opportunity")
async def reconciliation_create_opportunity(item_id: str):
opportunity_id = create_opportunity_from_reconciliation(item_id)
if opportunity_id:
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Oportunidade%20criada%20a%20partir%20de%20reconciliação", status_code=303)
return RedirectResponse("/reconciliation?notice=Não%20foi%20possível%20criar%20oportunidade", status_code=303)
@router.post("/reconciliation/{item_id}/link")
async def reconciliation_link(item_id: str, request: Request):
form = await request.form()
opportunity_id = str(form.get("opportunity_id") or "").strip()
if opportunity_id:
link_reconciliation_to_opportunity(item_id, opportunity_id)
return RedirectResponse(f"/opportunities/{opportunity_id}?notice=Item%20de%20reconciliação%20ligado", status_code=303)
return RedirectResponse("/reconciliation?notice=Indica%20o%20ID%20da%20oportunidade", status_code=303)