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

@@ -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"