753 lines
46 KiB
Python
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}¬ice={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}¬ice={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}¬ice={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}¬ice={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}¬ice={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}¬ice={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¬ice=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}¬ice={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¬ice=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¬ice=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)
|