diff --git a/app/domain/opportunity_flow/evidence.py b/app/domain/opportunity_flow/evidence.py index 1ffb621..5938d74 100644 --- a/app/domain/opportunity_flow/evidence.py +++ b/app/domain/opportunity_flow/evidence.py @@ -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 diff --git a/app/domain/opportunity_flow/rules.py b/app/domain/opportunity_flow/rules.py index 923fa35..9d3aec8 100644 --- a/app/domain/opportunity_flow/rules.py +++ b/app/domain/opportunity_flow/rules.py @@ -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, + ) diff --git a/app/domain/opportunity_flow/types.py b/app/domain/opportunity_flow/types.py index 86d2f13..8c91eca 100644 --- a/app/domain/opportunity_flow/types.py +++ b/app/domain/opportunity_flow/types.py @@ -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" diff --git a/app/jasmin_service.py b/app/jasmin_service.py index 6bf8c91..43b169e 100644 --- a/app/jasmin_service.py +++ b/app/jasmin_service.py @@ -609,7 +609,36 @@ async def create_quotation_for_opportunity(opportunity_id: str) -> Dict[str, Any except Exception: # operation_links é compatibilidade visual; não deve falhar o fluxo principal. 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} diff --git a/app/opportunity_action_task_materializer.py b/app/opportunity_action_task_materializer.py index 32863aa..b94d962 100644 --- a/app/opportunity_action_task_materializer.py +++ b/app/opportunity_action_task_materializer.py @@ -24,7 +24,8 @@ from app.work_center_action_policy import ( # v4928.1.5.96 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE"} # 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. MATERIALIZED_ACTIONS.add("CREATE_SHIPMENT") 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. config = get_action_config(action_code) default_routes = { + "SEND_QUOTE": "vendas", "SEND_INVOICE": "financeiro", "FOLLOW_UP_PAYMENT": "financeiro", "PREPARE_ORDER": "operacoes", @@ -125,6 +127,7 @@ def ensure_pending_task_for_next_action( route = "rever" default_labels = { + "SEND_QUOTE": "Enviar orçamento ao cliente", "SEND_INVOICE": "Enviar fatura ao cliente", "FOLLOW_UP_PAYMENT": "Follow-up pagamento", "PREPARE_ORDER": "Preparar encomenda / Odoo", @@ -133,6 +136,7 @@ def ensure_pending_task_for_next_action( "REVIEW_RECONSTRUCTED_PROCESS": "Validar processo reconstruído", } 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.", "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.", diff --git a/tests/domain/opportunity_flow/test_v4928_1_5_60_company_workflow_profiles.py b/tests/domain/opportunity_flow/test_v4928_1_5_60_company_workflow_profiles.py index 6cf99b4..52797ff 100644 --- a/tests/domain/opportunity_flow/test_v4928_1_5_60_company_workflow_profiles.py +++ b/tests/domain/opportunity_flow/test_v4928_1_5_60_company_workflow_profiles.py @@ -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.audit import audit_decisions +from app.domain.opportunity_flow.evidence import OpportunityEvidence def _profile(): @@ -106,7 +107,7 @@ def test_fiscal_conflict_blocks_financial_actions(): decision = decide_opportunity_next_action(evidence, _profile()) assert decision.next_action.code == "REVIEW" 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(): @@ -123,3 +124,165 @@ def test_after_delivery_invoice_without_payment_allows_prepare_odoo_before_payme decision = decide_opportunity_next_action(evidence, _profile()) assert decision.next_action.code == "PREPARE_ORDER" 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"