Compare commits
3 Commits
main
...
fix/legacy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55d5e51852 | ||
|
|
7e521a377e | ||
|
|
5f0c151feb |
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
Reference in New Issue
Block a user