fix(opportunity-flow): separate quote creation from quote sending

This commit is contained in:
plx
2026-08-18 14:43:57 +00:00
parent 47f21888f9
commit 5f0c151feb
6 changed files with 323 additions and 16 deletions

View File

@@ -249,7 +249,15 @@ def build_opportunity_evidence(
quote = _find_doc(docs, QUOTE_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
invalid_payment_task = None

View File

@@ -8,6 +8,7 @@ from .types import (
ACTION_CLOSE_OPPORTUNITY,
ACTION_CONFIRM_PAYMENT,
ACTION_CREATE_QUOTE,
ACTION_SEND_QUOTE,
ACTION_FOLLOW_UP,
ACTION_FOLLOW_UP_PAYMENT,
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)
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)
# A ausência de cliente fiscal é prontidão operacional, não intenção
# comercial. Não deve substituir a próxima ação da oportunidade.
# 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:
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:
# 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(
profile,
ACTION_RECONCILE_DOCUMENTS,
@@ -151,9 +162,26 @@ def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProf
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.stage == "QUOTE_REQUESTED":
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.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)
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:
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"))
next_action = _action(
profile,
@@ -265,6 +295,43 @@ 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)
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)
# Um orçamento criado/associado ainda não significa orçamento enviado.
# 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(
profile,
ACTION_SEND_QUOTE,
f"Orçamento {e.quote_number or ''} criado/associado. Enviar o documento ao cliente.",
priority="alta",
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_number=e.quote_number,
)
available_actions.append(next_action)
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,
)
# 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(
@@ -279,7 +346,9 @@ def decide_blif_next_action(e: OpportunityEvidence, profile: CompanyWorkflowProf
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:
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"))
next_action = _action(
profile,
@@ -379,5 +448,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)
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)
if e.stage in {"NEW_LEAD", "INFO_REQUESTED", "INFO_SENT"}:
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_VALIDATE_FISCAL_CUSTOMER = "VALIDATE_FISCAL_CUSTOMER"
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_SEND_INVOICE = "SEND_INVOICE"
ACTION_CONFIRM_ORDER = "CONFIRM_ORDER"