fix(opportunity-flow): separate quote creation from quote sending
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user