Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View 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",
]

View 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

View 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

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

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

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

View 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 {}),
)

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

View 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"}

View 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