"""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'{esc(opportunity_label)}' 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"""
Sugestão encontrada em Operations
{esc(suggested_title)}
{esc(suggested_customer)}
{esc(suggested_action)} · {esc(suggested_reason)}
""" suggestion_html = f'
{suggestion_cards}
' 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""" """ actions = f"""
{suggestion_html}
Pesquisar oportunidade
{manual_link}
""" else: actions = 'Sem ações pendentes' decision = classify_reconciliation_item(item) decision_chip = f'{esc(decision.get("label") or "Decisão")}' rows += f"""
{esc(item.get('title'))}
{esc(item.get('description') or '')}
{esc(item.get('source_system'))} · {esc(item.get('external_id') or 'sem external_id')}
{esc(_type_label(item.get('external_type')))}
{esc(item.get('document_number') or '')}
{status_chip(item.get('status'))}
{esc(item.get('priority') or 'normal')}
{decision_chip}
{esc(decision.get('primary_decision') or '')}
{esc(item.get('customer_name') or item.get('linked_customer_name') or '—')}
{esc(item.get('customer_email') or '')}
{f'
NIF {esc(item.get("customer_tax_id"))}
' if item.get('customer_tax_id') else ''} {amount}
{esc(fmt_dt(item.get('document_date')))}
{opportunity_link} {actions} """ if not rows: rows = 'Sem itens de reconciliação para este filtro.' 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 """

Processos candidatos

Sem processos reconstruíveis na janela ativa. Itens isolados continuam disponíveis abaixo.
""" cards = "" for candidate in candidates[:8]: item_ids = ",".join(str(x) for x in candidate.get("item_ids") or []) tax_line = f'
NIF {esc(candidate.get("customer_tax_id"))}
' if candidate.get("customer_tax_id") else "" email_line = f'
{esc(candidate.get("customer_email"))}
' if candidate.get("customer_email") else "" amount = "" if candidate.get("amount") is not None: amount = f'{money(candidate.get("amount"), candidate.get("currency") or "EUR")}' identity_line = f'
Cliente fiscal: {esc(candidate.get("identity_reason") or candidate.get("match_key") or "—")}
' operation_label = _operation_label(candidate.get("operation_key")) operation_line = f'
Compra/processo: {esc(operation_label)}
' 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'{esc(review_label)}' decision = classify_reconciliation_process(candidate) decision_chip = f'{esc(decision.get("label") or "Decisão")}' reasons = "".join(f'
  • {esc(reason)}
  • ' for reason in (candidate.get("reasons") or [])[:5]) risks = "".join(f'
  • {esc(risk)}
  • ' for risk in (candidate.get("risks") or [])[:5]) explanation_html = ( '
    ' f'
    Motivos
    ' f'
    Riscos
    ' '
    ' ) timeline = "" for step in candidate.get("timeline") or []: timeline += f"""
    {esc(fmt_dt(step.get('document_date')))}
    {esc(step.get('label'))}
    {esc(step.get('document_number') or step.get('title') or '')}
    """ suggestion_html = "" suggestions = candidate.get("suggestions") or [] if suggestions: suggestion = suggestions[0] suggestion_html = f"""
    Sugestão de oportunidade existente
    {esc(suggestion.get('opportunity_title') or 'Oportunidade aberta')}
    {esc(suggestion.get('customer_name') or '')} · {esc(suggestion.get('reason') or 'possível correspondência')}
    """ cards += f"""

    {esc(candidate.get('customer_name') or 'Processo externo')}

    {email_line}{tax_line}
    {len(candidate.get('items') or [])} evidência(s) confiança {esc(candidate.get('confidence') or 'média')} {review_chip} {decision_chip} {amount}
    {identity_line}{operation_line}
    {esc(decision.get('primary_decision') or 'Decisão pendente')}{esc(decision.get('description') or '')}
    {explanation_html}
    Estado sugerido{esc(candidate.get('suggested_stage') or 'REVIEW')}
    Próxima ação{esc(candidate.get('suggested_action') or 'REVIEW_MANUALLY')}
    {timeline}
    {suggestion_html}
    """ return f"""

    Processos candidatos

    O sistema agrupa primeiro por cliente fiscal e depois separa por compra/processo antes de criar ou ligar a oportunidade.
    {cards}
    """ 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''' {esc(label)} {int(summary.get(key, 0))} {esc(description)} ''' return f'''
    {html}
    ''' def render_reconciliation_table(items: list[dict]) -> str: return f"""
    {_render_item_rows(items)}
    ItemTipoEstadoClienteValor/dataOportunidadeAções
    """ @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'
    {esc(notice)}
    ' if notice else "" status_filters = "".join( f'{esc(label)}' 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'{esc(label)}' 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}
    Reconciliação = informação solta para organizar. 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.
    {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')}
    {render_reconciliation_decision_board(items, process_candidates, status=status, external_type=external_type, days=recent_days)}
    Estado:{status_filters}
    Tipo:{type_filters}
    Janela: {''.join(f'{label}' for value, label in [(1, 'Hoje'), (3, '3 dias'), (7, '7 dias'), (30, '30 dias')])}
    {_render_process_candidates(process_candidates, days=recent_days)}

    Itens soltos

    Itens individuais que ainda precisam de decisão ou que não formaram um processo candidato.
    {render_reconciliation_table(items)}

    Registar pedido externo

    Adicionar comprovativo

    Regra: comprovativo recebido abre validação financeira; não confirma pagamento.

    Criar candidato manual de reconciliação

    """ 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)