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