"""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"""
"""
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"""
Escolher outra oportunidade
"""
actions = f"""
{suggestion_html}
{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 {reasons or "candidato reconstruído por identidade fiscal/processo "} '
f'
Riscos {risks or "sem riscos relevantes detetados "} '
'
'
)
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"""
"""
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}
Ligar a outra oportunidade
"""
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'''
'''
def render_reconciliation_table(items: list[dict]) -> str:
return f"""
Item Tipo Estado Cliente Valor/data Oportunidade Ações
{_render_item_rows(items)}
"""
@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)}
Adicionar comprovativo
Regra: comprovativo recebido abre validação financeira; não confirma pagamento.
Nota
Guardar comprovativo
"""
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)