Files

373 lines
16 KiB
Python

from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from .types import PAYMENT_BEFORE_SHIPPING, DELIVERY_CARRIER, TERMINAL_STAGES
QUOTE_KINDS = {"quotation", "quote", "orc", "orcamento", "jasmin_quotation", "proforma", "jasmin_proforma"} # proforma is legacy alias only.
INVOICE_KINDS = {"invoice", "fatura", "fa", "ft", "jasmin_invoice"}
def _s(value: Any) -> str:
return str(value or "").strip()
def _upper(value: Any) -> str:
return _s(value).upper()
def _doc_number(doc: dict[str, Any] | None) -> str:
if not doc:
return ""
return _s(doc.get("document_number") or doc.get("external_id") or doc.get("number") or doc.get("id"))
@dataclass(frozen=True)
class OpportunityEvidence:
"""Normalized facts used by the workflow engine.
This object deliberately contains no HTML and no SQLAlchemy objects. Builders
can create it from DB rows, test fixtures or future integration adapters.
"""
opportunity_id: str = ""
company_profile: str = "blif"
stage: str = "NEW_LEAD"
status: str = "open"
is_terminal: bool = False
is_reconstructed: bool = False
has_fiscal_customer: bool = False
fiscal_identity_validated: bool = False
fiscal_data_complete: bool = False
has_fiscal_conflict: bool = False
has_nif_conflict: bool = False
has_quote: bool = False
quote_id: str | None = None
quote_number: str | None = None
quote_amount: float | None = None
# A completed SEND_QUOTE task is separate evidence from a linked Jasmin document.
# It prevents duplicate quote creation while still requiring document reconciliation.
quote_sent: bool = False
has_invoice: bool = False
invoice_id: str | None = None
invoice_number: str | None = None
invoice_amount: float | None = None
invoice_sent: bool | None = None
payment_confirmed: bool = False
payment_terms: str = PAYMENT_BEFORE_SHIPPING
delivery_terms: str = DELIVERY_CARRIER
has_odoo_sale: bool = False
odoo_in_production: bool = False
odoo_physical_ready: bool = False
odoo_physical_validated: bool = False
odoo_ready: bool = False
order_shipped: bool = False
order_delivered: bool = False
has_pending_task: bool = False
has_invalid_payment_task_without_document: bool = False
invalid_payment_task_id: str | None = None
invalid_payment_task_action_code: str | None = None
pending_task_id: str | None = None
pending_task_action_code: str | None = None
pending_task_label: str | None = None
pending_task_note: str | None = None
has_reconciliation_candidate: bool = False
reconciliation_label: str | None = None
messages: list[str] = field(default_factory=list)
@property
def primary_document_number(self) -> str:
return self.invoice_number or self.quote_number or ""
@property
def primary_document_id(self) -> str | None:
return self.invoice_id or self.quote_id
def _find_doc(docs: list[dict[str, Any]], kinds: set[str]) -> dict[str, Any] | None:
for doc in docs:
kind = _s(doc.get("document_kind") or doc.get("kind") or doc.get("type")).lower()
role = _s(doc.get("role") or "current").lower()
active = doc.get("is_active", True)
if kind in kinds and role in {"current", "accepted", "historical", "history"} and active is not False:
return doc
return None
def _amount(doc: dict[str, Any] | None) -> float | None:
if not doc:
return None
value = doc.get("total_amount", doc.get("amount", doc.get("total")))
try:
return float(value) if value not in (None, "") else None
except Exception:
return None
def _document_sent_evidence(doc: dict[str, Any] | None) -> bool:
"""Return True when the document row itself carries sent evidence.
Jasmin imports do not always expose a stable "sent" status. From
v4928.1.5.85 the repair/audit workflow can store local evidence on
commercial_documents.payload. Completed SEND_INVOICE tasks are handled by
_completed_send_invoice_task_evidence below because older rows often have
the evidence only in tasks, not in the document payload.
"""
if not doc:
return False
payload = _payload_dict(doc.get("payload"))
return bool(
doc.get("sent_at")
or doc.get("sent")
or _s(doc.get("status")).lower() in {"sent", "issued_sent"}
or payload.get("clientflow_invoice_sent_evidence")
or payload.get("invoice_sent_at")
)
def _task_is_completed(task: dict[str, Any]) -> bool:
status = _s(task.get("status")).lower()
return status in {
"done",
"completed",
"complete",
"closed",
"resolved",
"concluida",
"concluído",
"concluída",
} or bool(task.get("completed_at"))
def _completed_send_quote_task_evidence(tasks: list[dict[str, Any]]) -> bool:
"""Return True when this opportunity already has a completed SEND_QUOTE task.
Sending a quote and linking its Jasmin row are distinct facts. Older flows
often completed the operator task before reconciliation imported the
document. Treating the missing document as "quote not sent" caused duplicate
quote creation and obsolete quote follow-ups.
"""
for task in tasks or []:
if _upper(task.get("action_code")) == "SEND_QUOTE" and _task_is_completed(task):
return True
return False
def _completed_send_invoice_task_evidence(tasks: list[dict[str, Any]], invoice: dict[str, Any] | None) -> bool:
from app.invoice_evidence import completed_send_invoice_task_evidence
if not invoice:
return False
return completed_send_invoice_task_evidence(tasks, [invoice])
def _operation_card(snapshot: dict[str, Any] | None, key: str) -> dict[str, Any]:
for card in (snapshot or {}).get("cards", []) or []:
if _s(card.get("key")) == key:
return card if isinstance(card, dict) else {}
return {}
def _operation_status(snapshot: dict[str, Any] | None, key: str) -> str:
return _s(_operation_card(snapshot, key).get("status"))
def _payload_dict(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, str) and value.strip():
try:
import json
parsed = json.loads(value)
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def _payload_list(value: Any) -> list[Any]:
if isinstance(value, list):
return value
if isinstance(value, str) and value.strip():
try:
import json
parsed = json.loads(value)
return parsed if isinstance(parsed, list) else []
except Exception:
return []
return []
def _operation_link_exists(snapshot: dict[str, Any] | None, *, system: str, external_type: str, statuses: set[str] | None = None) -> bool:
statuses = statuses or set()
for link in (snapshot or {}).get("links", []) or []:
if _s(link.get("system")) == system and _s(link.get("external_type")) == external_type:
return not statuses or _s(link.get("status")) in statuses
return False
def build_opportunity_evidence(
opportunity: dict[str, Any] | None = None,
*,
linked_documents: list[dict[str, Any]] | None = None,
tasks: list[dict[str, Any]] | None = None,
operation_snapshot: dict[str, Any] | None = None,
linked_customer: dict[str, Any] | None = None,
fiscal_data_complete: bool | None = None,
has_reconciliation_candidate: bool = False,
reconciliation_label: str | None = None,
company_profile: str = "blif",
) -> OpportunityEvidence:
"""Normalize current opportunity state into engine evidence.
The builder is intentionally defensive: it accepts plain dictionaries from
UI services and from tests. Future adapters for other ERPs/CRMs should map
their source payloads into the same fields instead of changing the rules.
"""
opportunity = opportunity or {}
docs = list(linked_documents or [])
tasks = list(tasks or [])
metadata = opportunity.get("metadata") if isinstance(opportunity.get("metadata"), dict) else {}
stage = _upper(opportunity.get("stage") or "NEW_LEAD")
status = _s(opportunity.get("status") or "open").lower()
terminal = status == "closed" or stage in TERMINAL_STAGES
record_mode = _s(metadata.get("clientflow_record_mode"))
quote = _find_doc(docs, QUOTE_KINDS)
invoice = _find_doc(docs, INVOICE_KINDS)
quote_sent = bool(quote) or _completed_send_quote_task_evidence(tasks)
pending_task = None
invalid_payment_task = None
for task in tasks:
if _s(task.get("status")) == "pending":
action_code = _upper(task.get("action_code"))
# A pending payment-confirmation task is not actionable without any
# commercial document. Old triage sometimes inferred
# CONFIRM_PAYMENT merely from subjects like "Orçamento 2026/193".
# Ignore it as the primary task so the workflow can require a quote
# or document association first.
if action_code in {"CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT"} and not (quote or invoice):
invalid_payment_task = invalid_payment_task or task
continue
pending_task = task
break
payment_confirmed = _operation_link_exists(
operation_snapshot,
system="clientflow",
external_type="payment",
statuses={"confirmed"},
) or stage == "PAYMENT_CONFIRMED"
odoo_sale_status = _operation_status(operation_snapshot, "odoo_sale_order")
# O ClientFlow deve decidir pelo estado da venda/encomenda e sobretudo pelo
# picking/WH-OUT. Ordens de fabrico/MO são detalhe técnico do Odoo e não
# devem bloquear nem conduzir a próxima ação comercial.
production_status = _operation_status(operation_snapshot, "odoo_physical_status")
physical_status_card = _operation_card(operation_snapshot, "odoo_physical_status")
physical_payload = _payload_dict(physical_status_card.get("payload"))
physical_status = _s(physical_status_card.get("status") or physical_payload.get("physical_status") or physical_payload.get("status")).lower()
physical_text = " ".join(
_s(physical_payload.get(key))
for key in ("label", "reason", "next_action", "state", "status", "physical_status")
).casefold()
outgoing_pickings = _payload_list(physical_payload.get("outgoing_pickings") or physical_payload.get("pickings"))
picking_states = {
_s(p.get("state")).lower()
for p in outgoing_pickings
if isinstance(p, dict) and _s(p.get("state"))
}
physical_done = (
bool(physical_payload.get("delivery_done"))
or physical_status in {"done", "shipped", "delivered", "validated"}
or (bool(picking_states) and picking_states <= {"done", "cancel"} and "done" in picking_states)
)
physical_ready = (
bool(physical_payload.get("ready_to_ship") or physical_payload.get("delivery_ready"))
or physical_status in {"ready_to_ship", "validated", "ready"}
or "assigned" in picking_states
)
physical_indicates_production = (
bool(physical_status_card)
and not physical_done
and not physical_ready
and (
physical_status not in {"", "not_created", "not_found", "no_order", "cancelled", "shipped", "done", "delivered", "validated"}
or any(token in physical_text for token in ("produção", "produc", "prepara", "confirmed", "aguardar", "waiting", "em curso"))
)
)
physical_validation_status = _operation_status(operation_snapshot, "physical_validation")
shipment_status = _operation_status(operation_snapshot, "packlink_shipment")
if fiscal_data_complete is None:
fiscal_data_complete = bool(linked_customer and linked_customer.get("tax_id"))
has_conflict = bool(
opportunity.get("has_nif_conflict")
or metadata.get("has_nif_conflict")
or metadata.get("fiscal_conflict")
)
invoice_sent = _document_sent_evidence(invoice) or _completed_send_invoice_task_evidence(tasks, invoice)
return OpportunityEvidence(
opportunity_id=_s(opportunity.get("id")),
company_profile=company_profile,
stage=stage,
status=status,
is_terminal=terminal,
is_reconstructed=record_mode in {"reconstructed_invoice_review", "historical_reconstructed", "legacy_review"},
has_fiscal_customer=bool(linked_customer or opportunity.get("fiscal_customer_id") or opportunity.get("customer_id")),
fiscal_identity_validated=bool(linked_customer or opportunity.get("fiscal_customer_id")),
fiscal_data_complete=bool(fiscal_data_complete),
has_fiscal_conflict=has_conflict,
has_nif_conflict=has_conflict,
has_quote=bool(quote),
quote_id=_s(quote.get("id")) if quote else None,
quote_number=_doc_number(quote) or None,
quote_amount=_amount(quote),
quote_sent=quote_sent,
has_invoice=bool(invoice),
invoice_id=_s(invoice.get("id")) if invoice else None,
invoice_number=_doc_number(invoice) or None,
invoice_amount=_amount(invoice),
# invoice_sent=None if not invoice else _document_sent_evidence(invoice)
invoice_sent=None if not invoice else invoice_sent,
payment_confirmed=payment_confirmed,
payment_terms=_s(metadata.get("payment_terms") or PAYMENT_BEFORE_SHIPPING),
delivery_terms=_s(metadata.get("delivery_terms") or DELIVERY_CARRIER),
has_odoo_sale=odoo_sale_status not in {"", "not_created", "no_order", "not_found"} or _operation_link_exists(operation_snapshot, system="odoo", external_type="sale_order"),
odoo_in_production=(not physical_done) and (physical_indicates_production or stage in {"IN_PRODUCTION"}),
# Odoo picking state ``assigned`` only means stock is reserved/available.
# It is eligible for a human physical validation, not yet for shipment.
odoo_physical_ready=(not physical_done) and physical_ready,
odoo_physical_validated=physical_validation_status in {"validated", "ready_to_ship"},
odoo_ready=(not physical_done) and physical_ready and physical_validation_status in {"validated", "ready_to_ship"},
# For workflow decisions, "shipped" means the Odoo delivery/picking flow
# is closed/done. A Packlink shipment or a manufacturing order must not,
# by itself, move the opportunity to close.
order_shipped=physical_done or stage in {"SHIPPED"},
order_delivered=physical_done or stage in {"DELIVERED", "WON"} or _operation_status(operation_snapshot, "delivery") == "delivered",
has_pending_task=bool(pending_task),
has_invalid_payment_task_without_document=bool(invalid_payment_task),
invalid_payment_task_id=_s(invalid_payment_task.get("id")) if invalid_payment_task else None,
invalid_payment_task_action_code=_upper(invalid_payment_task.get("action_code")) if invalid_payment_task else None,
pending_task_id=_s(pending_task.get("id")) if pending_task else None,
pending_task_action_code=_upper(pending_task.get("action_code")) if pending_task else None,
pending_task_label=_s(pending_task.get("action") or pending_task.get("label")) if pending_task else None,
pending_task_note=_s(pending_task.get("note") or pending_task.get("action")) if pending_task else None,
has_reconciliation_candidate=bool(has_reconciliation_candidate),
reconciliation_label=reconciliation_label,
)