3 Commits

7 changed files with 375 additions and 29 deletions

View File

@@ -249,7 +249,15 @@ def build_opportunity_evidence(
quote = _find_doc(docs, QUOTE_KINDS) quote = _find_doc(docs, QUOTE_KINDS)
invoice = _find_doc(docs, INVOICE_KINDS) invoice = _find_doc(docs, INVOICE_KINDS)
quote_sent = bool(quote) or _completed_send_quote_task_evidence(tasks) # A existência do documento comercial prova apenas que o orçamento foi
# criado/associado. O envio ao cliente exige evidência própria.
#
# Compatibilidade histórica: QUOTE_SENT é também uma afirmação canónica
# explícita de que o orçamento já foi enviado.
quote_sent = (
stage == "QUOTE_SENT"
or _completed_send_quote_task_evidence(tasks)
)
pending_task = None pending_task = None
invalid_payment_task = None invalid_payment_task = None

View File

@@ -8,6 +8,7 @@ from .types import (
ACTION_CLOSE_OPPORTUNITY, ACTION_CLOSE_OPPORTUNITY,
ACTION_CONFIRM_PAYMENT, ACTION_CONFIRM_PAYMENT,
ACTION_CREATE_QUOTE, ACTION_CREATE_QUOTE,
ACTION_SEND_QUOTE,
ACTION_FOLLOW_UP, ACTION_FOLLOW_UP,
ACTION_FOLLOW_UP_PAYMENT, ACTION_FOLLOW_UP_PAYMENT,
ACTION_NO_ACTION, ACTION_NO_ACTION,
@@ -123,16 +124,26 @@ def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProf
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) 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: if not e.has_fiscal_customer:
blocked_actions.extend(_blocked(profile, code, "cliente fiscal por associar") for code in SENSITIVE_DOCUMENT_ACTIONS) # A ausência de cliente fiscal é prontidão operacional, não intenção
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) # comercial. Não deve substituir a próxima ação da oportunidade.
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) # O bloqueio fiscal é aplicado apenas mais abaixo quando uma transição
# concreta (por exemplo faturação após pagamento confirmado) exige
# efetivamente os dados fiscais.
warnings.append(
"Cliente fiscal ainda não associado; validar apenas quando uma "
"operação documental atual exigir dados fiscais."
)
if e.has_reconciliation_candidate: 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") 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) 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 not e.has_quote and not e.has_invoice:
if e.quote_sent: # Não promover automaticamente qualquer oportunidade para orçamento.
# A criação/reconciliação de orçamento só é trabalho atual quando o
# estágio comercial demonstra que o cliente pediu ou já recebeu um
# orçamento.
if e.stage == "QUOTE_SENT" and e.quote_sent:
next_action = _action( next_action = _action(
profile, profile,
ACTION_RECONCILE_DOCUMENTS, ACTION_RECONCILE_DOCUMENTS,
@@ -151,9 +162,26 @@ def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProf
profile_name=profile.name, profile_name=profile.name,
decision_version=profile.version, 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) if e.stage == "QUOTE_REQUESTED":
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) next_action = _action(
profile,
ACTION_CREATE_QUOTE,
"Criar/enviar orçamento solicitado pelo cliente.",
target_url=f"/opportunities/{e.opportunity_id}#documentos" if e.opportunity_id else None,
)
available_actions.append(next_action)
return OpportunityDecision(
next_action,
"Existe pedido de orçamento e ainda não há documento comercial 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.payment_terms == PAYMENT_AFTER_DELIVERY:
if e.stage == "SHIPMENT_CREATED" and not e.payment_confirmed: if e.stage == "SHIPMENT_CREATED" and not e.payment_confirmed:
@@ -226,7 +254,9 @@ def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProf
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) 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) 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 e.odoo_ready and not e.order_shipped:
if not e.has_invoice and e.has_fiscal_customer and not e.fiscal_data_complete: if not e.has_invoice and (
not e.has_fiscal_customer or not e.fiscal_data_complete
):
blocked_actions.append(_blocked(profile, ACTION_SEND_INVOICE, "dados fiscais incompletos")) blocked_actions.append(_blocked(profile, ACTION_SEND_INVOICE, "dados fiscais incompletos"))
next_action = _action( next_action = _action(
profile, profile,
@@ -265,21 +295,76 @@ def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProf
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) 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) 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. # Um orçamento criado/associado ainda não significa orçamento enviado.
if e.payment_terms in {PAYMENT_BEFORE_SHIPPING, "", "undefined", "agreement"} and e.has_quote and not e.payment_confirmed: # O envio ao cliente é uma obrigação humana/documental própria e deve
# acontecer antes de qualquer etapa de pagamento.
if (
e.has_quote
and not e.quote_sent
and not e.has_invoice
and not e.payment_confirmed
):
next_action = _action( next_action = _action(
profile, profile,
ACTION_CONFIRM_PAYMENT, ACTION_SEND_QUOTE,
f"Orçamento {e.quote_number or ''} associado. Confirmar pagamento antes de emitir fatura.", f"Orçamento {e.quote_number or ''} criado/associado. Enviar o documento ao cliente.",
priority="alta", priority="alta",
target_url=f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None, target_url=(
f"/tasks/{e.pending_task_id}"
if e.pending_task_id and e.pending_task_action_code == ACTION_SEND_QUOTE
else f"/opportunities/{e.opportunity_id}#documentos"
if e.opportunity_id
else None
),
document_id=e.quote_id, document_id=e.quote_id,
document_number=e.quote_number, document_number=e.quote_number,
) )
available_actions.append(next_action) 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) return OpportunityDecision(
next_action,
"O orçamento existe, mas ainda não há evidência de que tenha sido enviado ao cliente.",
available_actions=available_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 and e.has_fiscal_customer and not e.fiscal_data_complete: # 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 e.quote_sent
and not e.has_invoice
and not e.payment_confirmed
):
next_action = _action(
profile,
ACTION_NO_ACTION,
f"Orçamento {e.quote_number or ''} enviado. Aguardar decisão do cliente ou evidência de pagamento.",
force_label="Aguardar cliente / pagamento",
target_url=f"/opportunities/{e.opportunity_id}#operacao" if e.opportunity_id else None,
document_id=e.quote_id,
document_number=e.quote_number,
)
return OpportunityDecision(
next_action,
"O orçamento foi enviado e ainda não existe evidência de pagamento que exija validação. "
"Aguardar o cliente; o follow-up comercial assume quando ficar devido.",
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 (
not e.has_fiscal_customer or not e.fiscal_data_complete
):
blocked_actions.append(_blocked(profile, ACTION_SEND_INVOICE, "dados fiscais incompletos")) blocked_actions.append(_blocked(profile, ACTION_SEND_INVOICE, "dados fiscais incompletos"))
next_action = _action( next_action = _action(
profile, profile,
@@ -379,5 +464,38 @@ def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProf
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) 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) 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) if e.stage in {"NEW_LEAD", "INFO_REQUESTED", "INFO_SENT"}:
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) next_action = _action(
profile,
ACTION_NO_ACTION,
"Sem transição documental atual. Manter o estágio comercial e aguardar a próxima obrigação operacional real.",
target_url=f"/opportunities/{e.opportunity_id}" if e.opportunity_id else None,
)
return OpportunityDecision(
next_action,
"O estágio comercial atual não exige orçamento, faturação ou follow-up imediato gerado pelo motor central.",
warnings=warnings,
commercial_stage=e.stage,
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=e.stage or COMMERCIAL_STAGE_QUOTE_SENT,
financial_state=_financial_state(e),
physical_state=_physical_state(e),
profile_name=profile.name,
decision_version=profile.version,
)

View File

@@ -21,7 +21,8 @@ ACTION_NO_ACTION = "NO_ACTION"
ACTION_OPEN_TASK = "OPEN_TASK" ACTION_OPEN_TASK = "OPEN_TASK"
ACTION_VALIDATE_FISCAL_CUSTOMER = "VALIDATE_FISCAL_CUSTOMER" ACTION_VALIDATE_FISCAL_CUSTOMER = "VALIDATE_FISCAL_CUSTOMER"
ACTION_RECONCILE_DOCUMENTS = "RECONCILE_DOCUMENTS" ACTION_RECONCILE_DOCUMENTS = "RECONCILE_DOCUMENTS"
ACTION_CREATE_QUOTE = "CREATE_JASMIN_QUOTE" ACTION_CREATE_QUOTE = "CREATE_QUOTE"
ACTION_SEND_QUOTE = "SEND_QUOTE"
ACTION_CONFIRM_PAYMENT = "CONFIRM_PAYMENT" ACTION_CONFIRM_PAYMENT = "CONFIRM_PAYMENT"
ACTION_SEND_INVOICE = "SEND_INVOICE" ACTION_SEND_INVOICE = "SEND_INVOICE"
ACTION_CONFIRM_ORDER = "CONFIRM_ORDER" ACTION_CONFIRM_ORDER = "CONFIRM_ORDER"

View File

@@ -609,7 +609,36 @@ async def create_quotation_for_opportunity(opportunity_id: str) -> Dict[str, Any
except Exception: except Exception:
# operation_links é compatibilidade visual; não deve falhar o fluxo principal. # operation_links é compatibilidade visual; não deve falhar o fluxo principal.
pass pass
set_opportunity_stage(opportunity_id, "QUOTE_SENT", note="Orçamento Jasmin criado via ClientFlow.", created_by="jasmin_service") # Criar o documento no Jasmin não significa que foi enviado ao cliente.
# Mantemos o estágio de pedido até a ação SEND_QUOTE ser concluída.
set_opportunity_stage(
opportunity_id,
"QUOTE_REQUESTED",
note="Orçamento criado no Jasmin; falta enviar ao cliente.",
created_by="jasmin_service",
)
# Materializar a próxima obrigação humana usando o mecanismo central,
# preservando idempotência, route, prioridade e ligação à oportunidade.
try:
from app.opportunity_next_action_service import get_opportunity_next_action
from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action
ensure_pending_task_for_next_action(
opportunity_id,
get_opportunity_next_action(opportunity_id),
source="jasmin_quotation_created",
actor="jasmin_service",
)
except Exception as exc:
# O orçamento Jasmin já foi criado com sucesso. Uma falha de
# materialização não pode duplicar/reverter a criação externa.
print(
f"ClientFlow SEND_QUOTE materialization failed for opportunity "
f"{opportunity_id}: {exc}",
flush=True,
)
return {"customer": customer, "quotation": doc, "quotation_id": quotation_id, "payload": payload} return {"customer": customer, "quotation": doc, "quotation_id": quotation_id, "payload": payload}

View File

@@ -24,7 +24,8 @@ from app.work_center_action_policy import (
# v4928.1.5.96 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE"} # v4928.1.5.96 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE"}
# v4928.1.5.105 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE", "FOLLOW_UP_PAYMENT"} # v4928.1.5.105 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE", "FOLLOW_UP_PAYMENT"}
MATERIALIZED_ACTIONS = {"SEND_INVOICE", "FOLLOW_UP_PAYMENT", "PREPARE_ORDER"} # v4928.1.5.116 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE", "FOLLOW_UP_PAYMENT", "PREPARE_ORDER"}
MATERIALIZED_ACTIONS = {"SEND_QUOTE", "SEND_INVOICE", "FOLLOW_UP_PAYMENT", "PREPARE_ORDER"}
# v4928.1.5.129: central workflow emits SHIP_ORDER; operator tasks persist CREATE_SHIPMENT. # v4928.1.5.129: central workflow emits SHIP_ORDER; operator tasks persist CREATE_SHIPMENT.
MATERIALIZED_ACTIONS.add("CREATE_SHIPMENT") MATERIALIZED_ACTIONS.add("CREATE_SHIPMENT")
MATERIALIZED_ACTIONS.add("VALIDATE_PHYSICAL_ORDER") MATERIALIZED_ACTIONS.add("VALIDATE_PHYSICAL_ORDER")
@@ -109,6 +110,7 @@ def ensure_pending_task_for_next_action(
# pre-insert branch referenced these values before assignment. # pre-insert branch referenced these values before assignment.
config = get_action_config(action_code) config = get_action_config(action_code)
default_routes = { default_routes = {
"SEND_QUOTE": "vendas",
"SEND_INVOICE": "financeiro", "SEND_INVOICE": "financeiro",
"FOLLOW_UP_PAYMENT": "financeiro", "FOLLOW_UP_PAYMENT": "financeiro",
"PREPARE_ORDER": "operacoes", "PREPARE_ORDER": "operacoes",
@@ -125,6 +127,7 @@ def ensure_pending_task_for_next_action(
route = "rever" route = "rever"
default_labels = { default_labels = {
"SEND_QUOTE": "Enviar orçamento ao cliente",
"SEND_INVOICE": "Enviar fatura ao cliente", "SEND_INVOICE": "Enviar fatura ao cliente",
"FOLLOW_UP_PAYMENT": "Follow-up pagamento", "FOLLOW_UP_PAYMENT": "Follow-up pagamento",
"PREPARE_ORDER": "Preparar encomenda / Odoo", "PREPARE_ORDER": "Preparar encomenda / Odoo",
@@ -133,6 +136,7 @@ def ensure_pending_task_for_next_action(
"REVIEW_RECONSTRUCTED_PROCESS": "Validar processo reconstruído", "REVIEW_RECONSTRUCTED_PROCESS": "Validar processo reconstruído",
} }
default_descriptions = { default_descriptions = {
"SEND_QUOTE": "Orçamento criado/associado. Enviar PDF/proposta ao cliente e registar evidência.",
"SEND_INVOICE": "Fatura criada/associada. Enviar PDF ao cliente e registar evidência.", "SEND_INVOICE": "Fatura criada/associada. Enviar PDF ao cliente e registar evidência.",
"FOLLOW_UP_PAYMENT": "Encomenda concluída no Odoo/WH-OUT e fatura enviada. Acompanhar pagamento pós-entrega.", "FOLLOW_UP_PAYMENT": "Encomenda concluída no Odoo/WH-OUT e fatura enviada. Acompanhar pagamento pós-entrega.",
"PREPARE_ORDER": "Fatura e pagamento confirmados. Criar/validar venda Odoo e preparação da encomenda.", "PREPARE_ORDER": "Fatura e pagamento confirmados. Criar/validar venda Odoo e preparação da encomenda.",

View File

@@ -1,5 +1,6 @@
from app.domain.opportunity_flow import build_opportunity_evidence, decide_opportunity_next_action, load_company_profile from app.domain.opportunity_flow import build_opportunity_evidence, decide_opportunity_next_action, load_company_profile
from app.domain.opportunity_flow.audit import audit_decisions from app.domain.opportunity_flow.audit import audit_decisions
from app.domain.opportunity_flow.evidence import OpportunityEvidence
def _profile(): def _profile():
@@ -16,18 +17,39 @@ def test_blif_profile_loads_defaults_and_labels():
assert profile.documents["invoice"]["label"] == "Fatura" assert profile.documents["invoice"]["label"] == "Fatura"
def test_quote_before_shipping_requires_confirm_payment_before_invoice(): def test_sent_quote_waits_for_customer_or_payment_evidence():
evidence = build_opportunity_evidence( evidence = build_opportunity_evidence(
{"id": "opp-1", "stage": "QUOTE_SENT", "metadata": {"payment_terms": "before_shipping"}}, {
linked_customer={"id": "c1", "tax_id": "123", "billing_email": "a@b.pt", "address": "Rua", "postal_code": "1000", "city": "Lisboa"}, "id": "opp-1",
linked_documents=[{"id": "q1", "document_kind": "quotation", "document_number": "ORC.ORC2026.177", "total_amount": 202.95}], "stage": "QUOTE_SENT",
"metadata": {"payment_terms": "before_shipping"},
},
linked_customer={
"id": "c1",
"tax_id": "123",
"billing_email": "a@b.pt",
"address": "Rua",
"postal_code": "1000",
"city": "Lisboa",
},
linked_documents=[
{
"id": "q1",
"document_kind": "quotation",
"document_number": "ORC.ORC2026.177",
"total_amount": 202.95,
}
],
operation_snapshot={"links": [], "cards": []}, operation_snapshot={"links": [], "cards": []},
fiscal_data_complete=True, fiscal_data_complete=True,
) )
decision = decide_opportunity_next_action(evidence, _profile()) decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code == "CONFIRM_PAYMENT"
assert decision.next_action.code == "NO_ACTION"
assert decision.commercial_stage == "WAITING_PAYMENT"
assert decision.next_action.document_number == "ORC.ORC2026.177" assert decision.next_action.document_number == "ORC.ORC2026.177"
assert "antes da fatura" in decision.next_action.description or "antes de emitir fatura" in decision.next_action.description assert "Aguardar" in decision.next_action.description
def test_payment_confirmed_without_invoice_sends_invoice(): def test_payment_confirmed_without_invoice_sends_invoice():
@@ -106,7 +128,7 @@ def test_fiscal_conflict_blocks_financial_actions():
decision = decide_opportunity_next_action(evidence, _profile()) decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code == "REVIEW" assert decision.next_action.code == "REVIEW"
blocked = {a.code for a in decision.blocked_actions} blocked = {a.code for a in decision.blocked_actions}
assert {"CONFIRM_PAYMENT", "SEND_INVOICE", "CREATE_JASMIN_QUOTE"} <= blocked assert {"CONFIRM_PAYMENT", "SEND_INVOICE", "CREATE_QUOTE"} <= blocked
def test_after_delivery_invoice_without_payment_allows_prepare_odoo_before_payment(): def test_after_delivery_invoice_without_payment_allows_prepare_odoo_before_payment():
@@ -123,3 +145,165 @@ def test_after_delivery_invoice_without_payment_allows_prepare_odoo_before_payme
decision = decide_opportunity_next_action(evidence, _profile()) decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code == "PREPARE_ORDER" assert decision.next_action.code == "PREPARE_ORDER"
assert "sem pagamento prévio" in decision.reason or "sem exigir pagamento" in decision.next_action.description assert "sem pagamento prévio" in decision.reason or "sem exigir pagamento" in decision.next_action.description
def test_new_lead_without_fiscal_customer_does_not_make_fiscal_validation_primary():
evidence = build_opportunity_evidence(
{
"id": "opp-new-no-fiscal",
"stage": "NEW_LEAD",
"metadata": {"payment_terms": "before_shipping"},
},
linked_customer=None,
linked_documents=[],
operation_snapshot={"links": [], "cards": []},
fiscal_data_complete=False,
)
decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code != "VALIDATE_FISCAL_CUSTOMER"
assert decision.financial_state == "no_document"
def test_payment_confirmed_without_fiscal_customer_requires_fiscal_validation():
evidence = build_opportunity_evidence(
{
"id": "opp-paid-no-fiscal",
"stage": "PAYMENT_CONFIRMED",
"metadata": {"payment_terms": "before_shipping"},
},
linked_customer=None,
linked_documents=[
{
"id": "q-paid",
"document_kind": "quotation",
"document_number": "ORC.TEST.1",
}
],
operation_snapshot={
"links": [
{
"system": "clientflow",
"external_type": "payment",
"status": "confirmed",
}
],
"cards": [],
},
fiscal_data_complete=False,
)
decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code == "VALIDATE_FISCAL_CUSTOMER"
assert decision.financial_state == "payment_confirmed"
def _base_no_document_evidence(stage: str):
return build_opportunity_evidence(
{
"id": f"opp-{stage.lower()}",
"stage": stage,
"metadata": {"payment_terms": "before_shipping"},
},
linked_customer=None,
linked_documents=[],
operation_snapshot={"links": [], "cards": []},
fiscal_data_complete=False,
)
def test_new_lead_without_document_is_not_promoted_to_quote_or_fiscal():
decision = decide_opportunity_next_action(
_base_no_document_evidence("NEW_LEAD"),
_profile(),
)
assert decision.next_action.code == "NO_ACTION"
assert decision.commercial_stage == "NEW_LEAD"
def test_info_sent_without_document_is_not_promoted_to_quote():
decision = decide_opportunity_next_action(
_base_no_document_evidence("INFO_SENT"),
_profile(),
)
assert decision.next_action.code == "NO_ACTION"
assert decision.commercial_stage == "INFO_SENT"
def test_quote_requested_without_document_creates_quote():
decision = decide_opportunity_next_action(
_base_no_document_evidence("QUOTE_REQUESTED"),
_profile(),
)
assert decision.next_action.code == "CREATE_QUOTE"
def test_quote_sent_without_linked_document_and_with_send_evidence_reconciles():
evidence = build_opportunity_evidence(
{
"id": "opp-quote-sent",
"stage": "QUOTE_SENT",
"metadata": {"payment_terms": "before_shipping"},
},
linked_customer=None,
linked_documents=[],
tasks=[
{
"id": "task-send-quote",
"action_code": "SEND_QUOTE",
"status": "completed",
}
],
operation_snapshot={
"links": [],
"cards": [],
},
fiscal_data_complete=False,
)
decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code == "RECONCILE_DOCUMENTS"
def test_created_quote_must_be_sent_before_payment_confirmation():
evidence = OpportunityEvidence(
opportunity_id="opp-created-quote",
stage="QUOTE_REQUESTED",
has_fiscal_customer=True,
fiscal_identity_validated=True,
fiscal_data_complete=True,
has_quote=True,
quote_sent=False,
payment_confirmed=False,
quote_id="quote-1",
quote_number="ORC.TEST.1",
)
decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code == "SEND_QUOTE"
def test_sent_quote_can_advance_beyond_send_quote():
evidence = OpportunityEvidence(
opportunity_id="opp-sent-quote",
stage="QUOTE_SENT",
has_fiscal_customer=True,
fiscal_identity_validated=True,
fiscal_data_complete=True,
has_quote=True,
quote_sent=True,
payment_confirmed=False,
quote_id="quote-2",
quote_number="ORC.TEST.2",
)
decision = decide_opportunity_next_action(evidence, _profile())
assert decision.next_action.code != "SEND_QUOTE"

View File

@@ -33,6 +33,7 @@ def test_pending_send_quote_task_is_not_sent_evidence():
def test_quote_sent_without_document_requires_reconciliation(): def test_quote_sent_without_document_requires_reconciliation():
evidence = OpportunityEvidence( evidence = OpportunityEvidence(
opportunity_id="opp-1", opportunity_id="opp-1",
stage="QUOTE_SENT",
has_fiscal_customer=True, has_fiscal_customer=True,
fiscal_identity_validated=True, fiscal_identity_validated=True,
fiscal_data_complete=True, fiscal_data_complete=True,
@@ -48,12 +49,13 @@ def test_quote_sent_without_document_requires_reconciliation():
def test_no_quote_evidence_still_creates_quote(): def test_no_quote_evidence_still_creates_quote():
evidence = OpportunityEvidence( evidence = OpportunityEvidence(
opportunity_id="opp-2", opportunity_id="opp-2",
stage="QUOTE_REQUESTED",
has_fiscal_customer=True, has_fiscal_customer=True,
fiscal_identity_validated=True, fiscal_identity_validated=True,
fiscal_data_complete=True, fiscal_data_complete=True,
) )
decision = decide_blif_next_action(evidence, load_company_profile("blif")) decision = decide_blif_next_action(evidence, load_company_profile("blif"))
assert decision.next_action.code == "CREATE_JASMIN_QUOTE" assert decision.next_action.code == "CREATE_QUOTE"
def test_send_proforma_policy_schedules_payment_followup(): def test_send_proforma_policy_schedules_payment_followup():