Implement document reconciliation v2
This commit is contained in:
@@ -47,6 +47,13 @@ _opportunity_board_column_for_stage = legacy._opportunity_board_column_for_stage
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _authenticated_actor(request: Request) -> str:
|
||||
actor = str(getattr(request.state, "clientflow_admin_user", "") or "").strip()
|
||||
if not actor:
|
||||
raise ValueError("Identidade administrativa autenticada em falta.")
|
||||
return actor
|
||||
|
||||
PAYMENT_TERM_LABELS = {
|
||||
"before_shipping": "Antes do envio",
|
||||
"after_delivery": "Após entrega",
|
||||
@@ -223,16 +230,21 @@ def _opportunity_invoice_sent_evidence(opportunity_id: str, invoice_number: str
|
||||
def _document_display_number(doc: dict | None) -> str:
|
||||
if not doc:
|
||||
return "—"
|
||||
return str(doc.get("document_number") or doc.get("external_id") or doc.get("id") or "documento")
|
||||
return str(doc.get("document_number") or doc.get("external_id")
|
||||
or doc.get("document_id") or doc.get("id") or "documento")
|
||||
|
||||
|
||||
def _finance_quick_card_html(opportunity_id: str, linked_documents: list[dict], payment_term: str, payment_term_label: str) -> str:
|
||||
# BLIF default flow: quotation -> payment -> invoice -> prepare/ship.
|
||||
quotation = next((d for d in linked_documents if str(d.get("document_kind") or "") in {"quotation", "proforma"} and str(d.get("role") or "current") in {"current", "accepted"}), None)
|
||||
invoice = next((d for d in linked_documents if str(d.get("document_kind") or "") == "invoice" and str(d.get("role") or "current") in {"current", "accepted"}), None)
|
||||
quotation = next((d for d in linked_documents if str(d.get("document_kind") or "") in {"quotation", "proforma"}
|
||||
and str(d.get("relationship") or "").upper() == "PRIMARY"
|
||||
and str(d.get("jasmin_status") or d.get("status") or "").upper() not in {"CANCELLED", "CANCELED"}), None)
|
||||
invoice = next((d for d in linked_documents if str(d.get("document_kind") or "") == "invoice"
|
||||
and str(d.get("relationship") or "").upper() == "PRIMARY"
|
||||
and str(d.get("jasmin_status") or d.get("status") or "").upper() not in {"CANCELLED", "CANCELED"}), None)
|
||||
payment_confirmed = _opportunity_payment_confirmed(opportunity_id)
|
||||
base_doc = invoice or quotation
|
||||
base_doc_label = "Fatura" if invoice else ("Orçamento" if quotation else "Documento")
|
||||
base_doc_label = "Fatura" if invoice else ("Orçamento" if quotation else "Documento principal por definir")
|
||||
amount = (base_doc.get("total_amount") or base_doc.get("amount")) if base_doc else None
|
||||
amount_html = money_html(float(amount or 0)) if amount else "—"
|
||||
payment_status = "Confirmado" if payment_confirmed else ("Pendente pós-entrega" if payment_term == "after_delivery" else "Por confirmar")
|
||||
@@ -1813,26 +1825,28 @@ async def opportunity_detail_page(opportunity_id: str, notice: Optional[str] = N
|
||||
(
|
||||
doc for doc in linked_documents
|
||||
if str(doc.get("document_kind") or "") == "invoice"
|
||||
and str(doc.get("role") or "current") in {"current", "accepted"}
|
||||
and bool(doc.get("is_primary", True))
|
||||
and str(doc.get("relationship") or "").upper() == "PRIMARY"
|
||||
),
|
||||
next(
|
||||
(
|
||||
doc for doc in linked_documents
|
||||
if str(doc.get("role") or "current") in {"current", "accepted"}
|
||||
and bool(doc.get("is_primary", True))
|
||||
if str(doc.get("relationship") or "").upper() == "PRIMARY"
|
||||
),
|
||||
linked_documents[0] if linked_documents else None,
|
||||
None,
|
||||
),
|
||||
)
|
||||
opportunity_items_total = sum(
|
||||
float(item.get("total_price") or 0)
|
||||
for item in opportunity_items
|
||||
if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED", "DELIVERED", "HISTORICAL"}
|
||||
and str((item.get("metadata") or {}).get("source_system") or "manual").lower() in {"manual", "odoo", "operational"}
|
||||
and bool((item.get("metadata") or {}).get("approved", True))
|
||||
)
|
||||
document_value = float(primary_document.get("total_amount") or primary_document.get("amount") or 0) if primary_document else 0
|
||||
estimated_value = document_value or opportunity_items_total or float(opportunity.get("value_amount") or 0)
|
||||
value_source = "documento principal" if document_value else ("linhas atuais" if opportunity_items_total else "oportunidade")
|
||||
value_source = "documento principal" if document_value else (
|
||||
"valor manual/operacional aprovado" if opportunity_items_total else "documento principal por definir"
|
||||
)
|
||||
operation_snapshot = get_operation_snapshot(opportunity_id)
|
||||
opportunity_for_cockpit = dict(opportunity)
|
||||
opportunity_for_cockpit["pending_task_count"] = len(pending_tasks)
|
||||
@@ -2614,26 +2628,24 @@ async def opportunity_products_partial(opportunity_id: str):
|
||||
@router.post("/commercial-documents/{document_id}/unlink-from-opportunity")
|
||||
async def commercial_document_unlink_from_opportunity_action(document_id: str, request: Request):
|
||||
form = await request.form()
|
||||
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
||||
remove_lines = str(form.get("remove_imported_lines") or "1") == "1"
|
||||
note = str(form.get("note") or "").strip() or "Documento removido manualmente desta oportunidade; pertence a outra compra/processo."
|
||||
from app.document_reconciliation_service import resolve_document_opportunity
|
||||
opportunity_id = str(resolve_document_opportunity(document_id) or "")
|
||||
note = str(form.get("reason") or form.get("note") or "").strip()
|
||||
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
|
||||
return PlainTextResponse("Identificador inválido.", status_code=422)
|
||||
try:
|
||||
result = unlink_commercial_document_from_opportunity(
|
||||
opportunity_id,
|
||||
document_id,
|
||||
remove_imported_lines=remove_lines,
|
||||
note=note,
|
||||
actor="operator_ui_document_unlink",
|
||||
)
|
||||
if not note:
|
||||
raise ValueError("O motivo é obrigatório.")
|
||||
from app.document_reconciliation_service import remove_document
|
||||
remove_document(opportunity_id, document_id, actor=_authenticated_actor(request), reason=note,
|
||||
request_id=request.headers.get("x-request-id"), correlation_id=request.headers.get("x-correlation-id"))
|
||||
result = {"document_unlinked": 1, "imported_lines_deleted": 0}
|
||||
except Exception as exc:
|
||||
if request.headers.get("hx-request"):
|
||||
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao desassociar documento: {exc}"), status_code=409)
|
||||
return PlainTextResponse(f"Erro ao desassociar documento: {exc}", status_code=500)
|
||||
notice = (
|
||||
"Documento desassociado desta oportunidade. "
|
||||
f"Linhas importadas removidas: {result.get('imported_lines_deleted', 0)}."
|
||||
"Relação removida desta oportunidade; documento, linhas e histórico foram preservados."
|
||||
)
|
||||
if request.headers.get("hx-request"):
|
||||
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
||||
@@ -2643,30 +2655,78 @@ async def commercial_document_unlink_from_opportunity_action(document_id: str, r
|
||||
@router.post("/commercial-documents/{document_id}/role")
|
||||
async def commercial_document_role_action(document_id: str, request: Request):
|
||||
form = await request.form()
|
||||
opportunity_id = str(form.get("opportunity_id") or "").strip()
|
||||
role = str(form.get("role") or "current").strip().lower()
|
||||
from app.document_reconciliation_service import resolve_document_opportunity
|
||||
opportunity_id = str(resolve_document_opportunity(document_id) or "")
|
||||
role = str(form.get("relationship") or form.get("role") or "PRIMARY").strip().upper()
|
||||
make_primary = str(form.get("make_primary") or "1") == "1"
|
||||
reason = str(form.get("reason") or "").strip()
|
||||
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
|
||||
return PlainTextResponse("Identificador inválido.", status_code=422)
|
||||
try:
|
||||
result = set_commercial_document_role_for_opportunity(
|
||||
opportunity_id,
|
||||
document_id,
|
||||
role=role,
|
||||
make_primary=make_primary,
|
||||
actor="operator_ui_document_role",
|
||||
)
|
||||
from app.document_reconciliation_service import set_document_relationship
|
||||
legacy_map = {"CURRENT": "PRIMARY", "ACCEPTED": "SECONDARY", "RELATED": "SECONDARY", "HISTORICAL": "HISTORICAL"}
|
||||
relationship = legacy_map.get(role, role)
|
||||
if make_primary: relationship = "PRIMARY"
|
||||
if relationship == "PRIMARY" and not reason:
|
||||
raise ValueError("O motivo é obrigatório para substituir/confirmar o principal.")
|
||||
result = set_document_relationship(opportunity_id, document_id, relationship,
|
||||
actor=_authenticated_actor(request), reason=reason or f"Marcado {relationship} na UI",
|
||||
request_id=request.headers.get("x-request-id"), correlation_id=request.headers.get("x-correlation-id"))
|
||||
except Exception as exc:
|
||||
if request.headers.get("hx-request"):
|
||||
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Erro ao atualizar papel do documento: {exc}"), status_code=409)
|
||||
return PlainTextResponse(f"Erro ao atualizar papel do documento: {exc}", status_code=500)
|
||||
label = {"current": "atual", "accepted": "aceite", "related": "relacionado", "historical": "histórico"}.get(result.get("role"), role)
|
||||
label = str(result.get("relationship") or role).lower()
|
||||
notice = f"Documento marcado como {label}."
|
||||
if request.headers.get("hx-request"):
|
||||
return HTMLResponse(jasmin_documents_html(opportunity_id, notice=notice))
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}?notice={quote(notice)}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/opportunities/{opportunity_id}/documents/{document_id}/relationship")
|
||||
async def document_reconciliation_v2_action(opportunity_id: str, document_id: str, request: Request):
|
||||
"""Canonical v2 HTTP transition; never mutates the remote Jasmin document."""
|
||||
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
|
||||
return PlainTextResponse("Identificador inválido.", status_code=422)
|
||||
form = await request.form()
|
||||
relationship = str(form.get("relationship") or "").upper().strip()
|
||||
reason = str(form.get("reason") or "").strip()
|
||||
destination = str(form.get("destination_opportunity_id") or "").strip()
|
||||
try:
|
||||
from app.document_reconciliation_service import (
|
||||
reassign_document, restore_document, set_document_relationship,
|
||||
)
|
||||
common = {"actor": _authenticated_actor(request), "reason": reason,
|
||||
"request_id": request.headers.get("x-request-id"),
|
||||
"correlation_id": request.headers.get("x-correlation-id")}
|
||||
if relationship == "REASSIGNED":
|
||||
if not is_uuid_text(destination): raise ValueError("O destino é obrigatório e deve ser válido.")
|
||||
reassign_document(opportunity_id, destination, document_id, **common)
|
||||
elif relationship == "RESTORE":
|
||||
restore_document(opportunity_id, document_id, **common)
|
||||
else:
|
||||
set_document_relationship(opportunity_id, document_id, relationship, **common)
|
||||
except Exception as exc:
|
||||
if request.headers.get("hx-request"):
|
||||
return HTMLResponse(jasmin_documents_html(opportunity_id, error_notice=f"Reconciliação não aplicada: {exc}"), status_code=409)
|
||||
return PlainTextResponse(f"Reconciliação não aplicada: {exc}", status_code=409)
|
||||
if request.headers.get("hx-request"):
|
||||
return HTMLResponse(jasmin_documents_html(opportunity_id, notice="Relação ClientFlow atualizada; o Jasmin não foi alterado."))
|
||||
return RedirectResponse(f"/opportunities/{opportunity_id}#documentos", status_code=303)
|
||||
|
||||
|
||||
@router.get("/opportunities/{opportunity_id}/documents/{document_id}/history")
|
||||
async def document_reconciliation_v2_history(opportunity_id: str, document_id: str):
|
||||
if not is_uuid_text(opportunity_id) or not is_uuid_text(document_id):
|
||||
return PlainTextResponse("Identificador inválido.", status_code=422)
|
||||
from app.document_reconciliation_service import get_document_link, list_document_link_events
|
||||
if not get_document_link(opportunity_id, document_id, include_ended=True):
|
||||
return PlainTextResponse("Documento não pertence ao histórico desta oportunidade.", status_code=404)
|
||||
events = list_document_link_events(opportunity_id, document_id=document_id)
|
||||
rows = "".join(f"<tr><td>{esc(fmt_dt(e.get('created_at')))}</td><td>{esc(e.get('event_type'))}</td><td>{esc(e.get('actor'))}</td><td>{esc(e.get('old_relationship') or '—')} → {esc(e.get('new_relationship') or '—')}</td><td>{esc(e.get('reason') or '—')}</td></tr>" for e in events)
|
||||
return HTMLResponse(f'<section id="document-history-panel" class="card"><div class="card-body"><h3>Histórico de reconciliação</h3><table class="table"><thead><tr><th>Data</th><th>Evento</th><th>Operador</th><th>Relação</th><th>Motivo</th></tr></thead><tbody>{rows}</tbody></table></div></section>')
|
||||
|
||||
|
||||
@router.post("/commercial-documents/{document_id}/refresh")
|
||||
async def commercial_document_refresh(document_id: str, request: Request):
|
||||
form = await request.form()
|
||||
@@ -3425,4 +3485,3 @@ async def opportunity_odoo_link_candidate_action(opportunity_id: str, item_id: s
|
||||
return HTMLResponse(odoo_status_panel_html(opportunity_id, error_notice=f"Erro ao associar venda Odoo: {exc}"), status_code=409)
|
||||
return PlainTextResponse(f"Erro ao associar venda Odoo: {exc}", status_code=500)
|
||||
return RedirectResponse(url=f"/opportunities/{opportunity_id}?notice=Venda%20Odoo%20associada", status_code=303)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user