373 lines
16 KiB
Python
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,
|
|
)
|