Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
16
app/domain/opportunity_flow/__init__.py
Normal file
16
app/domain/opportunity_flow/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
"""Company-configurable opportunity workflow engine."""
|
||||
|
||||
from .evidence import OpportunityEvidence, build_opportunity_evidence
|
||||
from .decision import OpportunityDecision, WorkflowAction
|
||||
from .engine import decide_opportunity_next_action
|
||||
from .profiles import CompanyWorkflowProfile, load_company_profile
|
||||
|
||||
__all__ = [
|
||||
"OpportunityEvidence",
|
||||
"OpportunityDecision",
|
||||
"WorkflowAction",
|
||||
"build_opportunity_evidence",
|
||||
"decide_opportunity_next_action",
|
||||
"CompanyWorkflowProfile",
|
||||
"load_company_profile",
|
||||
]
|
||||
20
app/domain/opportunity_flow/audit.py
Normal file
20
app/domain/opportunity_flow/audit.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from typing import Iterable
|
||||
|
||||
from .decision import OpportunityDecision
|
||||
from .validators import decision_coherence_flags
|
||||
|
||||
|
||||
def audit_decisions(decisions: Iterable[OpportunityDecision]) -> list[dict[str, object]]:
|
||||
"""Return workflow inconsistencies from already computed decisions.
|
||||
|
||||
This foundation helper is intentionally data-source agnostic. A later admin
|
||||
page can feed production opportunities into it and render a coherence panel.
|
||||
"""
|
||||
findings: list[dict[str, object]] = []
|
||||
for index, decision in enumerate(decisions):
|
||||
for code in decision_coherence_flags(decision):
|
||||
findings.append({"index": index, "code": code, "decision": asdict(decision.next_action)})
|
||||
return findings
|
||||
51
app/domain/opportunity_flow/decision.py
Normal file
51
app/domain/opportunity_flow/decision.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkflowAction:
|
||||
code: str
|
||||
label: str
|
||||
description: str = ""
|
||||
priority: str = "normal"
|
||||
target_url: str | None = None
|
||||
can_execute: bool = True
|
||||
reason_if_blocked: str | None = None
|
||||
document_id: str | None = None
|
||||
document_number: str | None = None
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpportunityDecision:
|
||||
"""Single operational decision consumed by UI, tasks and audits."""
|
||||
|
||||
next_action: WorkflowAction
|
||||
reason: str
|
||||
available_actions: list[WorkflowAction] = field(default_factory=list)
|
||||
blocked_actions: list[WorkflowAction] = field(default_factory=list)
|
||||
warnings: list[str] = field(default_factory=list)
|
||||
commercial_stage: str = "REVIEW"
|
||||
financial_state: str = "unknown"
|
||||
physical_state: str = "unknown"
|
||||
ui_hints: dict[str, Any] = field(default_factory=dict)
|
||||
decision_version: str = "opportunity-flow-engine-v1"
|
||||
profile_name: str = "default"
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
data = asdict(self)
|
||||
# Backward-compatible aliases used by older UI/service code.
|
||||
data["action_code"] = self.next_action.code
|
||||
data["label"] = self.next_action.label
|
||||
data["description"] = self.next_action.description or self.reason
|
||||
data["priority"] = self.next_action.priority
|
||||
data["target_url"] = self.next_action.target_url
|
||||
data["can_execute"] = self.next_action.can_execute
|
||||
data["reason_if_blocked"] = self.next_action.reason_if_blocked
|
||||
data["document_id"] = self.next_action.document_id
|
||||
data["document_number"] = self.next_action.document_number
|
||||
return data
|
||||
15
app/domain/opportunity_flow/engine.py
Normal file
15
app/domain/opportunity_flow/engine.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .decision import OpportunityDecision
|
||||
from .evidence import OpportunityEvidence
|
||||
from .profiles import CompanyWorkflowProfile, load_company_profile
|
||||
from .rules import decide_blif_next_action
|
||||
|
||||
|
||||
def decide_opportunity_next_action(evidence: OpportunityEvidence, profile: CompanyWorkflowProfile | None = None) -> OpportunityDecision:
|
||||
"""Run the company workflow profile against normalized evidence."""
|
||||
|
||||
profile = profile or load_company_profile(evidence.company_profile or "blif")
|
||||
# v1 foundation ships with BLIF and default profiles. New companies can add
|
||||
# profiles without changing UI; custom rule modules can be introduced later.
|
||||
return decide_blif_next_action(evidence, profile)
|
||||
372
app/domain/opportunity_flow/evidence.py
Normal file
372
app/domain/opportunity_flow/evidence.py
Normal file
@@ -0,0 +1,372 @@
|
||||
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,
|
||||
)
|
||||
7
app/domain/opportunity_flow/labels.py
Normal file
7
app/domain/opportunity_flow/labels.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .profiles import load_company_profile
|
||||
|
||||
|
||||
def workflow_action_label(code: str, company: str = "blif", fallback: str | None = None) -> str:
|
||||
return load_company_profile(company).action_label(code, fallback)
|
||||
82
app/domain/opportunity_flow/profiles.py
Normal file
82
app/domain/opportunity_flow/profiles.py
Normal file
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
PROFILE_ROOT = ROOT / "config" / "company_profiles"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompanyWorkflowProfile:
|
||||
company: str
|
||||
name: str
|
||||
version: str
|
||||
defaults: dict[str, Any] = field(default_factory=dict)
|
||||
documents: dict[str, Any] = field(default_factory=dict)
|
||||
payment_terms: dict[str, str] = field(default_factory=dict)
|
||||
delivery_terms: dict[str, str] = field(default_factory=dict)
|
||||
commercial_stages: list[dict[str, str]] = field(default_factory=list)
|
||||
actions: dict[str, Any] = field(default_factory=dict)
|
||||
followups: dict[str, Any] = field(default_factory=dict)
|
||||
ui: dict[str, Any] = field(default_factory=dict)
|
||||
email_intents: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def action_label(self, code: str, fallback: str | None = None) -> str:
|
||||
raw = self.actions.get(str(code or ""), {})
|
||||
if isinstance(raw, dict) and raw.get("label"):
|
||||
return str(raw["label"])
|
||||
return fallback or str(code or "Acompanhar")
|
||||
|
||||
def action_description(self, code: str, fallback: str = "") -> str:
|
||||
raw = self.actions.get(str(code or ""), {})
|
||||
if isinstance(raw, dict) and raw.get("description"):
|
||||
return str(raw["description"])
|
||||
return fallback
|
||||
|
||||
|
||||
def _read_yaml(path: Path) -> dict[str, Any]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
@lru_cache(maxsize=16)
|
||||
def load_company_profile(company: str = "blif") -> CompanyWorkflowProfile:
|
||||
"""Load a workflow profile from config/company_profiles.
|
||||
|
||||
Missing optional files are allowed so a new company can start with only a
|
||||
workflow.yaml and inherit conservative defaults.
|
||||
"""
|
||||
|
||||
key = str(company or "blif").strip().lower() or "blif"
|
||||
profile_dir = PROFILE_ROOT / key
|
||||
if not profile_dir.exists():
|
||||
profile_dir = PROFILE_ROOT / "default"
|
||||
key = "default"
|
||||
|
||||
workflow = _read_yaml(profile_dir / "workflow.yaml")
|
||||
labels = _read_yaml(profile_dir / "labels.yaml")
|
||||
ui = _read_yaml(profile_dir / "ui.yaml")
|
||||
email_intents = _read_yaml(profile_dir / "email_intents.yaml")
|
||||
|
||||
return CompanyWorkflowProfile(
|
||||
company=str(workflow.get("company") or key),
|
||||
name=str(workflow.get("profile_name") or labels.get("profile_name") or key.upper()),
|
||||
version=str(workflow.get("version") or "workflow-profile-v1"),
|
||||
defaults=dict(workflow.get("defaults") or {}),
|
||||
documents=dict(workflow.get("documents") or {}),
|
||||
payment_terms=dict(workflow.get("payment_terms") or {}),
|
||||
delivery_terms=dict(workflow.get("delivery_terms") or {}),
|
||||
commercial_stages=list(workflow.get("commercial_stages") or []),
|
||||
actions=dict(labels.get("actions") or workflow.get("actions") or {}),
|
||||
followups=dict(workflow.get("followups") or {}),
|
||||
ui=dict(ui or workflow.get("ui") or {}),
|
||||
email_intents=dict(email_intents.get("email_intents") or email_intents or {}),
|
||||
)
|
||||
383
app/domain/opportunity_flow/rules.py
Normal file
383
app/domain/opportunity_flow/rules.py
Normal file
@@ -0,0 +1,383 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .decision import OpportunityDecision, WorkflowAction
|
||||
from .evidence import OpportunityEvidence
|
||||
from .profiles import CompanyWorkflowProfile
|
||||
from .types import (
|
||||
ACTION_CONFIRM_ORDER,
|
||||
ACTION_CLOSE_OPPORTUNITY,
|
||||
ACTION_CONFIRM_PAYMENT,
|
||||
ACTION_CREATE_QUOTE,
|
||||
ACTION_FOLLOW_UP,
|
||||
ACTION_FOLLOW_UP_PAYMENT,
|
||||
ACTION_NO_ACTION,
|
||||
ACTION_PREPARE_ORDER,
|
||||
ACTION_RECONCILE_DOCUMENTS,
|
||||
ACTION_REVIEW,
|
||||
ACTION_SEND_INVOICE,
|
||||
ACTION_SHIP_ORDER,
|
||||
ACTION_VALIDATE_PHYSICAL_ORDER,
|
||||
ACTION_VALIDATE_FISCAL_CUSTOMER,
|
||||
ACTION_WAIT_PRODUCTION,
|
||||
COMMERCIAL_STAGE_IN_EXECUTION,
|
||||
COMMERCIAL_STAGE_PAYMENT_CONFIRMED,
|
||||
COMMERCIAL_STAGE_QUOTE_SENT,
|
||||
COMMERCIAL_STAGE_REVIEW,
|
||||
COMMERCIAL_STAGE_WAITING_PAYMENT,
|
||||
COMMERCIAL_STAGE_WON,
|
||||
PAYMENT_AFTER_DELIVERY,
|
||||
PAYMENT_BEFORE_SHIPPING,
|
||||
)
|
||||
|
||||
|
||||
SENSITIVE_DOCUMENT_ACTIONS = {ACTION_CREATE_QUOTE, ACTION_SEND_INVOICE, ACTION_CONFIRM_PAYMENT}
|
||||
|
||||
|
||||
def _action(profile: CompanyWorkflowProfile, code: str, description: str = "", **kwargs: object) -> WorkflowAction:
|
||||
force_label = kwargs.pop("force_label", None)
|
||||
fallback_label = kwargs.pop("label", None)
|
||||
return WorkflowAction(
|
||||
code=code,
|
||||
label=str(force_label) if force_label else profile.action_label(code, fallback_label),
|
||||
description=description or profile.action_description(code, ""),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def _blocked(profile: CompanyWorkflowProfile, code: str, reason: str) -> WorkflowAction:
|
||||
return _action(profile, code, can_execute=False, reason_if_blocked=reason)
|
||||
|
||||
|
||||
def _financial_state(e: OpportunityEvidence) -> str:
|
||||
if e.payment_confirmed:
|
||||
return "payment_confirmed"
|
||||
if e.has_invoice:
|
||||
return "invoice_payment_pending"
|
||||
if e.has_quote:
|
||||
return "quote_payment_pending"
|
||||
return "no_document"
|
||||
|
||||
|
||||
def _physical_state(e: OpportunityEvidence) -> str:
|
||||
if e.order_delivered:
|
||||
return "delivered"
|
||||
if e.order_shipped:
|
||||
return "shipped"
|
||||
if e.odoo_ready:
|
||||
return "ready_to_ship"
|
||||
if e.odoo_in_production:
|
||||
return "in_production"
|
||||
if e.has_odoo_sale:
|
||||
return "odoo_sale"
|
||||
return "none"
|
||||
|
||||
|
||||
def _base_warnings(e: OpportunityEvidence) -> list[str]:
|
||||
warnings: list[str] = []
|
||||
if e.is_reconstructed:
|
||||
warnings.append("Registo reconstruído: validar pagamento, valor e documentos antes de executar ações sensíveis.")
|
||||
if e.fiscal_identity_validated and not e.fiscal_data_complete:
|
||||
warnings.append("Identidade fiscal associada, mas dados fiscais/envio podem estar incompletos.")
|
||||
if e.has_nif_conflict or e.has_fiscal_conflict:
|
||||
warnings.append("Existe conflito fiscal/NIF: bloquear documentos e pagamentos até validação.")
|
||||
if e.has_pending_task and e.pending_task_action_code == ACTION_SEND_INVOICE and e.has_invoice and e.payment_confirmed and e.invoice_sent is True:
|
||||
warnings.append("Há uma task SEND_INVOICE pendente, mas a fatura já tem evidência local de envio. Rever/ignorar a task para não repetir o envio.")
|
||||
elif e.has_pending_task and e.pending_task_action_code == ACTION_SEND_INVOICE and e.has_invoice and e.payment_confirmed and e.odoo_in_production:
|
||||
warnings.append("Há uma task pendente de envio de fatura, mas a evidência indica fatura existente e produção em curso. Rever se a task está obsoleta ou se falta apenas enviar o PDF ao cliente.")
|
||||
if e.payment_terms == PAYMENT_AFTER_DELIVERY and e.has_pending_task and e.pending_task_action_code == ACTION_SEND_INVOICE and e.has_odoo_sale and not e.odoo_ready and not e.order_shipped:
|
||||
warnings.append("Pagamento pós-entrega: task de fatura só deve avançar quando a encomenda estiver pronta para entrega/levantamento.")
|
||||
if e.has_invalid_payment_task_without_document:
|
||||
warnings.append("Existe uma task de pagamento pendente sem orçamento/fatura associado. Associar/criar documento comercial antes de confirmar pagamento.")
|
||||
return warnings
|
||||
|
||||
|
||||
def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProfile) -> OpportunityDecision:
|
||||
"""BLIF operational rules isolated from DB and HTML.
|
||||
|
||||
Protected sequence for the normal profile:
|
||||
information -> quote -> payment -> invoice -> production/shipping.
|
||||
The after-delivery payment term relaxes payment as a shipping blocker but
|
||||
keeps payment follow-up explicit after shipment/delivery.
|
||||
"""
|
||||
|
||||
warnings = _base_warnings(e)
|
||||
blocked_actions: list[WorkflowAction] = []
|
||||
available_actions: list[WorkflowAction] = []
|
||||
|
||||
if e.is_terminal:
|
||||
next_action = _action(profile, ACTION_NO_ACTION, target_url=f"/opportunities/{e.opportunity_id}" if e.opportunity_id else None)
|
||||
return OpportunityDecision(
|
||||
next_action=next_action,
|
||||
reason="A oportunidade está concluída/fechada.",
|
||||
warnings=warnings,
|
||||
commercial_stage=COMMERCIAL_STAGE_WON,
|
||||
financial_state=_financial_state(e),
|
||||
physical_state=_physical_state(e),
|
||||
profile_name=profile.name,
|
||||
decision_version=profile.version,
|
||||
)
|
||||
|
||||
if e.has_nif_conflict or e.has_fiscal_conflict:
|
||||
blocked_actions.extend(_blocked(profile, code, "conflito fiscal/NIF") for code in SENSITIVE_DOCUMENT_ACTIONS)
|
||||
next_action = _action(profile, ACTION_REVIEW, "Resolver conflito fiscal/NIF antes de avançar.", priority="alta", target_url=f"/opportunities/{e.opportunity_id}#cliente" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Conflito fiscal/NIF bloqueia ações financeiras.", blocked_actions=blocked_actions, warnings=warnings, commercial_stage=COMMERCIAL_STAGE_REVIEW, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if not e.has_fiscal_customer:
|
||||
blocked_actions.extend(_blocked(profile, code, "cliente fiscal por associar") for code in SENSITIVE_DOCUMENT_ACTIONS)
|
||||
next_action = _action(profile, ACTION_VALIDATE_FISCAL_CUSTOMER, "Associar/validar cliente fiscal antes de documentos oficiais.", priority="alta", target_url=f"/opportunities/{e.opportunity_id}#cliente" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Cliente fiscal ainda não associado.", blocked_actions=blocked_actions, warnings=warnings, commercial_stage=COMMERCIAL_STAGE_REVIEW, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.has_reconciliation_candidate:
|
||||
next_action = _action(profile, ACTION_RECONCILE_DOCUMENTS, f"Confirmar evidência encontrada: {e.reconciliation_label or 'documento/candidato'}.", priority="alta", target_url="/reconciliation")
|
||||
return OpportunityDecision(next_action, "Há evidência de reconciliação por validar.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_REVIEW, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if not e.has_quote and not e.has_invoice:
|
||||
if e.quote_sent:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_RECONCILE_DOCUMENTS,
|
||||
"O orçamento já foi enviado, mas falta associar o documento Jasmin e importar valor/linhas.",
|
||||
force_label="Associar orçamento enviado",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else "/reconciliation",
|
||||
)
|
||||
return OpportunityDecision(
|
||||
next_action,
|
||||
"Existe evidência de SEND_QUOTE concluído sem documento comercial associado; não criar um orçamento duplicado.",
|
||||
warnings=warnings,
|
||||
commercial_stage=COMMERCIAL_STAGE_REVIEW,
|
||||
financial_state="no_document",
|
||||
physical_state=_physical_state(e),
|
||||
profile_name=profile.name,
|
||||
decision_version=profile.version,
|
||||
)
|
||||
next_action = _action(profile, ACTION_CREATE_QUOTE, "Criar/enviar orçamento antes de pedir pagamento ou emitir fatura.", target_url=f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None)
|
||||
available_actions.append(next_action)
|
||||
return OpportunityDecision(next_action, "Ainda não há orçamento/fatura associado.", available_actions=available_actions, warnings=warnings, commercial_stage=COMMERCIAL_STAGE_REVIEW, financial_state="no_document", physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.payment_terms == PAYMENT_AFTER_DELIVERY:
|
||||
if e.stage == "SHIPMENT_CREATED" and not e.payment_confirmed:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_FOLLOW_UP_PAYMENT,
|
||||
"Envio já criado/registado e pagamento pós-entrega ainda pendente. Acompanhar pagamento.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None,
|
||||
document_id=e.invoice_id or e.quote_id,
|
||||
document_number=e.invoice_number or e.quote_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento pós-entrega: envio criado sem pagamento confirmado; fazer follow-up de pagamento.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
if e.order_delivered or e.order_shipped:
|
||||
if not e.has_invoice:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_SEND_INVOICE,
|
||||
f"Odoo/WH-OUT indica encomenda concluída. Emitir/enviar fatura com base em {e.quote_number or 'orçamento'} antes de acompanhar pagamento.",
|
||||
label="Emitir/enviar fatura",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None,
|
||||
document_id=e.quote_id,
|
||||
document_number=e.quote_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento pós-entrega: fatura deve existir e ser enviada antes do follow-up de pagamento ou fecho.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
if e.invoice_sent is not True:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_SEND_INVOICE,
|
||||
f"Fatura {e.invoice_number or ''} criada/associada. Enviar PDF ao cliente antes de acompanhar pagamento ou concluir.",
|
||||
priority="alta",
|
||||
target_url=f"/tasks/{e.pending_task_id}" if e.pending_task_id and e.pending_task_action_code == ACTION_SEND_INVOICE else (f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None),
|
||||
document_id=e.invoice_id,
|
||||
document_number=e.invoice_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Odoo/WH-OUT está concluído, mas ainda falta evidência de fatura enviada ao cliente.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
if not e.payment_confirmed:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_FOLLOW_UP_PAYMENT,
|
||||
"Encomenda concluída no Odoo e fatura enviada; acompanhar pagamento pós-entrega.",
|
||||
priority="alta",
|
||||
target_url=f"/tasks/{e.pending_task_id}" if e.pending_task_id and e.pending_task_action_code == ACTION_FOLLOW_UP_PAYMENT else (f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None),
|
||||
document_id=e.invoice_id,
|
||||
document_number=e.invoice_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento pós-entrega pendente depois de Odoo/WH-OUT concluído e fatura enviada.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_CLOSE_OPPORTUNITY,
|
||||
f"Fatura {e.invoice_number or ''} enviada, pagamento confirmado e Odoo/WH-OUT concluído. Concluir a oportunidade.",
|
||||
label="Concluir oportunidade",
|
||||
priority="normal",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None,
|
||||
document_id=e.invoice_id,
|
||||
document_number=e.invoice_number,
|
||||
)
|
||||
return OpportunityDecision(
|
||||
next_action,
|
||||
"Fatura enviada, pagamento confirmado e Odoo/WH-OUT concluído; por agora não criar follow-up de tracking/entrega.",
|
||||
warnings=warnings,
|
||||
commercial_stage=COMMERCIAL_STAGE_WON,
|
||||
financial_state=_financial_state(e),
|
||||
physical_state=_physical_state(e),
|
||||
profile_name=profile.name,
|
||||
decision_version=profile.version,
|
||||
)
|
||||
if not e.has_odoo_sale:
|
||||
next_action = _action(profile, ACTION_PREPARE_ORDER, "Pagamento após entrega: criar/associar venda Odoo e avançar preparação sem exigir pagamento confirmado.", priority="alta", target_url=f"/opportunities/{e.opportunity_id}#odoo" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Condição pós-entrega permite avançar Odoo/preparação sem pagamento prévio.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
if e.odoo_ready and not e.order_shipped:
|
||||
if not e.has_invoice and e.has_fiscal_customer and not e.fiscal_data_complete:
|
||||
blocked_actions.append(_blocked(profile, ACTION_SEND_INVOICE, "dados fiscais incompletos"))
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_VALIDATE_FISCAL_CUSTOMER,
|
||||
"Encomenda pronta para entrega/levantamento, mas faltam dados fiscais antes de emitir a fatura.",
|
||||
label="Completar dados fiscais",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#cliente" if e.opportunity_id else None,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento pós-entrega: a fatura deve ser emitida antes/no momento da entrega, mas a ficha fiscal está incompleta.", blocked_actions=blocked_actions, warnings=warnings, commercial_stage=COMMERCIAL_STAGE_REVIEW, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
if not e.has_invoice:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_SEND_INVOICE,
|
||||
f"Encomenda pronta para entrega/levantamento. Emitir/enviar fatura com prazo acordado com base em {e.quote_number or 'orçamento'}.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None,
|
||||
document_id=e.quote_id,
|
||||
document_number=e.quote_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento pós-entrega: faturar quando a encomenda está pronta para entrega/levantamento, antes de acompanhar pagamento.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
if e.invoice_sent is not True:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_SEND_INVOICE,
|
||||
f"Fatura {e.invoice_number or ''} criada/associada. Enviar PDF ao cliente para entrega/levantamento; o pagamento será acompanhado depois.",
|
||||
priority="alta",
|
||||
target_url=f"/tasks/{e.pending_task_id}" if e.pending_task_id and e.pending_task_action_code == ACTION_SEND_INVOICE else (f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None),
|
||||
document_id=e.invoice_id,
|
||||
document_number=e.invoice_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento pós-entrega: fatura existe, mas ainda falta envio ao cliente.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
next_action = _action(profile, ACTION_SHIP_ORDER, "Fatura pronta/enviada; avançar entrega/levantamento e acompanhar pagamento depois.", priority="alta", target_url=f"/opportunities/{e.opportunity_id}#odoo" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Pagamento não bloqueia entrega porque a condição é pós-entrega.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
if e.odoo_in_production or e.has_odoo_sale:
|
||||
next_action = _action(profile, ACTION_WAIT_PRODUCTION, "Pagamento após entrega: venda Odoo criada; aguardar WH/OUT ficar pronto/concluído antes de emitir fatura.", priority="normal", target_url=f"/opportunities/{e.opportunity_id}#odoo" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Aguardar estado da encomenda/WH-OUT no Odoo; ordens de fabrico são apenas detalhe técnico.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
# Default/BLIF normal sequence: budget document, payment, invoice, then preparation/shipping.
|
||||
if e.payment_terms in {PAYMENT_BEFORE_SHIPPING, "", "undefined", "agreement"} and e.has_quote and not e.payment_confirmed:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_CONFIRM_PAYMENT,
|
||||
f"Orçamento {e.quote_number or ''} associado. Confirmar pagamento antes de emitir fatura.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None,
|
||||
document_id=e.quote_id,
|
||||
document_number=e.quote_number,
|
||||
)
|
||||
available_actions.append(next_action)
|
||||
return OpportunityDecision(next_action, "Fluxo normal BLIF exige pagamento confirmado depois do orçamento e antes da fatura.", available_actions=available_actions, warnings=warnings, commercial_stage=COMMERCIAL_STAGE_WAITING_PAYMENT, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.payment_confirmed and not e.has_invoice and e.has_fiscal_customer and not e.fiscal_data_complete:
|
||||
blocked_actions.append(_blocked(profile, ACTION_SEND_INVOICE, "dados fiscais incompletos"))
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_VALIDATE_FISCAL_CUSTOMER,
|
||||
"Pagamento confirmado, mas faltam dados fiscais obrigatórios antes de emitir/enviar a fatura.",
|
||||
label="Completar dados fiscais",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#cliente" if e.opportunity_id else None,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento confirmado com dados fiscais incompletos; bloquear emissão de fatura até completar a ficha fiscal.", blocked_actions=blocked_actions, warnings=warnings, commercial_stage=COMMERCIAL_STAGE_REVIEW, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.payment_confirmed and not e.has_invoice:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_SEND_INVOICE,
|
||||
f"Pagamento confirmado com base em {e.quote_number or 'orçamento'}. Emitir/enviar fatura de seguida.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None,
|
||||
document_id=e.quote_id,
|
||||
document_number=e.quote_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Pagamento confirmado e ainda não há fatura associada.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_PAYMENT_CONFIRMED, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.has_invoice and not e.payment_confirmed and e.payment_terms != PAYMENT_AFTER_DELIVERY:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_CONFIRM_PAYMENT,
|
||||
f"Fatura {e.invoice_number or ''} associada; confirmar pagamento antes de envio/preparação final.",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None,
|
||||
document_id=e.invoice_id,
|
||||
document_number=e.invoice_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Fatura existe mas pagamento ainda não está confirmado.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_WAITING_PAYMENT, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
# A fatura pode já existir/estar emitida no Jasmin mas ainda faltar
|
||||
# enviá-la ao cliente. Isto é uma ação de comunicação/documento diferente
|
||||
# de “criar fatura” e deve aparecer antes de aguardar produção ou criar
|
||||
# envio sempre que não exista evidência local de envio do PDF ao cliente.
|
||||
if e.has_invoice and e.payment_confirmed and e.invoice_sent is not True:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_SEND_INVOICE,
|
||||
f"Fatura {e.invoice_number or ''} criada/associada. Enviar PDF ao cliente; depois acompanhar produção/preparação.",
|
||||
priority="alta",
|
||||
target_url=f"/tasks/{e.pending_task_id}" if e.pending_task_id and e.pending_task_action_code == ACTION_SEND_INVOICE else (f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None),
|
||||
document_id=e.invoice_id,
|
||||
document_number=e.invoice_number,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Fatura existe, mas o envio ao cliente ainda não está confirmado.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_PAYMENT_CONFIRMED, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.has_invoice and e.payment_confirmed and e.invoice_sent is True and (e.order_delivered or e.order_shipped):
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_CLOSE_OPPORTUNITY,
|
||||
f"Fatura {e.invoice_number or ''} enviada, pagamento confirmado e Odoo/WH-OUT concluído. Concluir a oportunidade.",
|
||||
label="Concluir oportunidade",
|
||||
priority="normal",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None,
|
||||
document_id=e.invoice_id,
|
||||
document_number=e.invoice_number,
|
||||
)
|
||||
return OpportunityDecision(
|
||||
next_action,
|
||||
"Fatura enviada, pagamento confirmado e Odoo/WH-OUT concluído; por agora não criar follow-up de tracking/entrega.",
|
||||
warnings=warnings,
|
||||
commercial_stage=COMMERCIAL_STAGE_WON,
|
||||
financial_state=_financial_state(e),
|
||||
physical_state=_physical_state(e),
|
||||
profile_name=profile.name,
|
||||
decision_version=profile.version,
|
||||
)
|
||||
|
||||
# Shipped/WH-OUT closed cases must be evaluated before production/wait states.
|
||||
# if e.has_invoice and e.payment_confirmed and e.order_shipped: Confirmar entrega/tracking
|
||||
|
||||
if e.has_invoice and e.payment_confirmed and e.odoo_in_production:
|
||||
next_action = _action(profile, ACTION_WAIT_PRODUCTION, f"Fatura {e.invoice_number or ''} e pagamento confirmados; aguardar WH/OUT ficar pronto/concluído no Odoo.", priority="normal", target_url=f"/opportunities/{e.opportunity_id}#odoo" if e.opportunity_id else None, document_id=e.invoice_id, document_number=e.invoice_number)
|
||||
return OpportunityDecision(next_action, "Aguardar estado da encomenda/WH-OUT no Odoo; ordens de fabrico são apenas detalhe técnico.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.has_invoice and e.payment_confirmed and e.odoo_physical_ready and not e.odoo_physical_validated and not e.order_shipped:
|
||||
next_action = _action(
|
||||
profile,
|
||||
ACTION_VALIDATE_PHYSICAL_ORDER,
|
||||
"O picking está reservado no Odoo. Confirmar que a encomenda está fisicamente preparada antes de criar o envio.",
|
||||
label="Validar encomenda física",
|
||||
priority="alta",
|
||||
target_url=f"/opportunities/{e.opportunity_id}#odoo" if e.opportunity_id else None,
|
||||
)
|
||||
return OpportunityDecision(next_action, "Odoo assigned confirma reserva de stock, não validação física da encomenda.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.has_invoice and e.payment_confirmed and e.odoo_ready and not e.order_shipped:
|
||||
next_action = _action(profile, ACTION_SHIP_ORDER, "Encomenda fisicamente validada; criar envio/tracking.", priority="alta", target_url=f"/opportunities/{e.opportunity_id}#odoo" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Pagamento/fatura OK e preparação física validada; avançar para expedição.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
if e.has_invoice and e.payment_confirmed and not e.has_odoo_sale:
|
||||
next_action = _action(profile, ACTION_PREPARE_ORDER, f"Fatura {e.invoice_number or ''} e pagamento confirmados. Criar/validar venda Odoo e preparação.", priority="alta", target_url=f"/opportunities/{e.opportunity_id}#odoo" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Fatura e pagamento OK; falta validar execução/Odoo.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_IN_EXECUTION, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
|
||||
next_action = _action(profile, ACTION_FOLLOW_UP, "Rever tarefas, documentos e próximos contactos.", priority="baixa", target_url=f"/opportunities/{e.opportunity_id}" if e.opportunity_id else None)
|
||||
return OpportunityDecision(next_action, "Sem regra específica aplicável; manter em acompanhamento.", warnings=warnings, commercial_stage=COMMERCIAL_STAGE_QUOTE_SENT, financial_state=_financial_state(e), physical_state=_physical_state(e), profile_name=profile.name, decision_version=profile.version)
|
||||
51
app/domain/opportunity_flow/types.py
Normal file
51
app/domain/opportunity_flow/types.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""Stable workflow vocabulary.
|
||||
|
||||
These constants are intentionally company-neutral. Company profiles can rename
|
||||
labels, but the engine uses these keys to keep rules testable.
|
||||
"""
|
||||
|
||||
PAYMENT_BEFORE_SHIPPING = "before_shipping"
|
||||
PAYMENT_AFTER_DELIVERY = "after_delivery"
|
||||
PAYMENT_AGREEMENT = "agreement"
|
||||
PAYMENT_UNDEFINED = "undefined"
|
||||
|
||||
DELIVERY_CARRIER = "carrier"
|
||||
DELIVERY_PICKUP = "pickup"
|
||||
DELIVERY_INSTALL_PARTNER = "install_partner"
|
||||
DELIVERY_UNDEFINED = "undefined"
|
||||
|
||||
DOCUMENT_QUOTE = "quote"
|
||||
DOCUMENT_INVOICE = "invoice"
|
||||
|
||||
ACTION_NO_ACTION = "NO_ACTION"
|
||||
ACTION_OPEN_TASK = "OPEN_TASK"
|
||||
ACTION_VALIDATE_FISCAL_CUSTOMER = "VALIDATE_FISCAL_CUSTOMER"
|
||||
ACTION_RECONCILE_DOCUMENTS = "RECONCILE_DOCUMENTS"
|
||||
ACTION_CREATE_QUOTE = "CREATE_JASMIN_QUOTE"
|
||||
ACTION_CONFIRM_PAYMENT = "CONFIRM_PAYMENT"
|
||||
ACTION_SEND_INVOICE = "SEND_INVOICE"
|
||||
ACTION_CONFIRM_ORDER = "CONFIRM_ORDER"
|
||||
ACTION_PREPARE_ORDER = "PREPARE_ORDER"
|
||||
ACTION_WAIT_PRODUCTION = "WAIT_PRODUCTION"
|
||||
ACTION_VALIDATE_PHYSICAL_ORDER = "VALIDATE_PHYSICAL_ORDER"
|
||||
ACTION_SHIP_ORDER = "SHIP_ORDER"
|
||||
ACTION_FOLLOW_UP_PAYMENT = "FOLLOW_UP_PAYMENT"
|
||||
ACTION_FOLLOW_UP = "FOLLOW_UP"
|
||||
ACTION_CLOSE_OPPORTUNITY = "CLOSE_OPPORTUNITY"
|
||||
ACTION_REVIEW = "REVIEW"
|
||||
|
||||
COMMERCIAL_STAGE_NEW = "NEW_LEAD"
|
||||
COMMERCIAL_STAGE_INFO_SENT = "INFO_SENT"
|
||||
COMMERCIAL_STAGE_QUOTE_SENT = "QUOTE_SENT"
|
||||
COMMERCIAL_STAGE_WAITING_PAYMENT = "WAITING_PAYMENT"
|
||||
COMMERCIAL_STAGE_PAYMENT_CONFIRMED = "PAYMENT_CONFIRMED"
|
||||
COMMERCIAL_STAGE_IN_EXECUTION = "ODOO_ORDER_CREATED"
|
||||
COMMERCIAL_STAGE_WON = "WON"
|
||||
COMMERCIAL_STAGE_LOST = "LOST"
|
||||
COMMERCIAL_STAGE_REVIEW = "REVIEW"
|
||||
|
||||
TERMINAL_STAGES = {"WON", "LOST", "NO_INTEREST", "DELIVERED", "ARCHIVED"}
|
||||
|
||||
# Legacy action names still present in old data. They are normalized at the
|
||||
# boundary so the rest of the engine can avoid showing "pró-forma" to operators.
|
||||
LEGACY_PROFORMA_ACTIONS = {"SEND_PROFORMA", "PROFORMA_REQUESTED", "PROFORMA_SENT"}
|
||||
16
app/domain/opportunity_flow/validators.py
Normal file
16
app/domain/opportunity_flow/validators.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .decision import OpportunityDecision
|
||||
from .types import ACTION_CONFIRM_PAYMENT, ACTION_SEND_INVOICE, ACTION_WAIT_PRODUCTION
|
||||
|
||||
|
||||
def decision_coherence_flags(decision: OpportunityDecision) -> list[str]:
|
||||
"""Small safety net used by future audits/tests."""
|
||||
flags: list[str] = []
|
||||
if decision.next_action.code == ACTION_CONFIRM_PAYMENT and decision.financial_state == "payment_confirmed":
|
||||
flags.append("payment_confirmed_but_action_is_confirm_payment")
|
||||
if decision.next_action.code == ACTION_SEND_INVOICE and decision.financial_state == "payment_confirmed" and decision.physical_state == "in_production":
|
||||
flags.append("invoice_send_action_while_production_should_be_wait")
|
||||
if decision.next_action.code == ACTION_WAIT_PRODUCTION and decision.physical_state != "in_production":
|
||||
flags.append("wait_production_without_production_state")
|
||||
return flags
|
||||
Reference in New Issue
Block a user