"""Behavior tests for v4.9.9 process timeline reconstruction. These tests validate the core reconstruction rules without touching the real production database: grouping is based on strong identity signals and the suggested stage/action is inferred from the most advanced external evidence. """ import sys import types # The project test suite is intentionally lightweight and mostly static. This # file exercises pure reconstruction helpers without requiring a live DB or # SQLAlchemy in the test environment. if "sqlalchemy" not in sys.modules: sqlalchemy = types.ModuleType("sqlalchemy") sqlalchemy.text = lambda sql: sql sys.modules["sqlalchemy"] = sqlalchemy if "app.db" not in sys.modules: app_db = types.ModuleType("app.db") app_db.engine = object() sys.modules["app.db"] = app_db if "app.action_catalog" not in sys.modules: action_catalog = types.ModuleType("app.action_catalog") action_catalog.get_action_config = lambda code: {"route": "rever", "action": code, "action_required": True, "safe_to_post": False} sys.modules["app.action_catalog"] = action_catalog from app import reconciliation_service as svc def test_process_state_reconstructs_quote_then_odoo_sale_as_invoice_check(): state = svc.infer_reconciliation_process_state([ { "id": "quote-1", "source_system": "jasmin", "external_type": "jasmin_quotation", "document_number": "ORC.ORC2026.157", "document_date": "2026-06-05", "amount": "320.00", }, { "id": "sale-1", "source_system": "odoo", "external_type": "odoo_sale_order", "document_number": "S00275", "document_date": "2026-06-05", "amount": "320.00", }, ]) assert state["stage"] == "ODOO_ORDER_CREATED" assert state["action_code"] == "SEND_INVOICE" assert [step["external_type"] for step in state["steps"]] == [ "jasmin_quotation", "odoo_sale_order", ] def test_process_state_payment_proof_requires_manual_payment_confirmation(): state = svc.infer_reconciliation_process_state([ { "id": "invoice-1", "source_system": "jasmin", "external_type": "jasmin_invoice", "document_number": "FA.FA2026.209", "document_date": "2026-06-05", "amount": "537.00", }, { "id": "proof-1", "source_system": "manual", "external_type": "payment_proof", "document_number": "comprovativo.jpg", "document_date": "2026-06-05", "amount": "537.00", }, ]) assert state["stage"] == "INVOICE_SENT" assert state["action_code"] == "CONFIRM_PAYMENT" assert any(step["external_type"] == "payment_proof" for step in state["steps"]) def test_process_candidates_group_by_exact_nif_and_ignore_unrelated_lone_items(monkeypatch): monkeypatch.setattr( svc, "list_reconciliation_items", lambda *, status, limit, days: [ { "id": "quote-1", "source_system": "jasmin", "external_type": "jasmin_quotation", "title": "Orçamento Jasmin sem oportunidade · ORC.ORC2026.157", "customer_name": "DOCTOR SOLUTION CONSTRUÇÕES, UNIPESSOAL, LDA", "customer_tax_id": "PT515708690", "document_number": "ORC.ORC2026.157", "document_date": "2026-06-05", "amount": "320.00", "currency": "EUR", }, { "id": "sale-1", "source_system": "odoo", "external_type": "odoo_sale_order", "title": "Venda Odoo sem oportunidade · S00275", "customer_name": "DOCTOR SOLUTION CONSTRUÇÕES, UNIPESSOAL, LDA", "customer_tax_id": "515708690", "document_number": "S00275", "document_date": "2026-06-05", "amount": "320.00", "currency": "EUR", }, { "id": "lone-1", "source_system": "jasmin", "external_type": "jasmin_quotation", "title": "Orçamento isolado", "customer_name": "CLIENTE SEM SEGUNDA EVIDÊNCIA", "customer_tax_id": "599999999", "document_number": "ORC.ISOLATED", "document_date": "2026-06-05", "amount": "10.00", "currency": "EUR", }, ], ) candidates = svc.list_reconciliation_process_candidates(status="open", days=3, limit=10) assert len(candidates) == 1 candidate = candidates[0] assert candidate["match_key"] == "nif" assert candidate["match_value"] == "515708690" assert candidate["confidence"] == "alta" assert candidate["item_ids"] == ["quote-1", "sale-1"] assert candidate["suggested_stage"] == "ODOO_ORDER_CREATED" assert candidate["suggested_action"] == "SEND_INVOICE" def test_single_item_with_open_operation_suggestion_becomes_process_candidate(monkeypatch): opportunity_id = "11111111-1111-1111-1111-111111111111" monkeypatch.setattr( svc, "list_reconciliation_items", lambda *, status, limit, days: [ { "id": "invoice-1", "source_system": "jasmin", "external_type": "jasmin_invoice", "title": "Fatura Jasmin sem oportunidade · FA.FA2026.209", "customer_name": "ELEGANTLEGACY, LDA", "customer_tax_id": "515510165", "document_number": "FA.FA2026.209", "document_date": "2026-06-05", "amount": "537.00", "currency": "EUR", "operation_suggestions": [ { "opportunity_id": opportunity_id, "title": "Elegantlegacy Lda · Emitir fatura", "reason": "NIF exato", } ], } ], ) candidates = svc.list_reconciliation_process_candidates(status="open", days=3, limit=10) assert len(candidates) == 1 assert candidates[0]["item_ids"] == ["invoice-1"] assert candidates[0]["suggestions"][0]["opportunity_id"] == opportunity_id assert candidates[0]["suggested_action"] == "CONFIRM_PAYMENT" def test_process_candidates_merge_jasmin_nif_with_odoo_name_only_evidence(monkeypatch): """Jasmin may provide NIF while Odoo only provides the fiscal name. The real operational process should still be shown as one candidate, not as an isolated Jasmin quote with an Odoo sale merely suggested elsewhere. """ monkeypatch.setattr( svc, "list_reconciliation_items", lambda *, status, limit, days: [ { "id": "quote-aczco", "source_system": "jasmin", "external_type": "jasmin_quotation", "title": "Orçamento Jasmin sem oportunidade · ORC.ORC2026.154", "customer_name": "ACZCO BRAGA ENERGY, LDA", "customer_tax_id": "517249200", "document_number": "ORC.ORC2026.154", "document_date": "2026-06-03", "amount": None, "currency": "EUR", "operation_suggestions": [], }, { "id": "sale-aczco", "source_system": "odoo", "external_type": "odoo_sale_order", "title": "Venda Odoo sem oportunidade · S00279", "customer_name": "ACZCO BRAGA ENERGY, LDA", "customer_tax_id": "", "document_number": "S00279", "document_date": "2026-06-05", "amount": "638.60", "currency": "EUR", "payload": { "record": { "invoice_status": "to invoice", "fulfilment": { "invoice_pending": True, "lines": [ {"product_name": "Wallbox 7.4KW"}, {"product_name": "RFID Reader"}, ], "outgoing_pickings": [{"name": "WH/OUT/00300", "state": "done", "date_done": "2026-06-05 15:12:00"}], }, } }, "operation_suggestions": [], }, ], ) candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10) assert len(candidates) == 1 candidate = candidates[0] assert candidate["match_key"] == "nif" assert candidate["match_value"] == "517249200" assert candidate["confidence"] == "alta" assert candidate["identity_reason"] == "NIF + nome fiscal" assert set(candidate["item_ids"]) == {"quote-aczco", "sale-aczco"} assert candidate["suggested_stage"] == "SHIPPED" assert candidate["suggested_action"] == "SEND_INVOICE" assert any(step["external_type"] == "jasmin_quotation" for step in candidate["timeline"]) assert any(step["external_type"] == "odoo_sale_order" for step in candidate["timeline"]) assert any(step["external_type"] == "odoo_delivery" for step in candidate["timeline"]) def test_process_candidates_bridge_jasmin_nif_to_odoo_name_with_location_suffix(monkeypatch): """Odoo may only expose a display name with address/location suffixes. Jasmin remains the fiscal identity source because it has the NIF. Odoo evidence should still join the same reconstructed process when its company name is a strong normalized name match. """ monkeypatch.setattr( svc, "list_reconciliation_items", lambda *, status, limit, days: [ { "id": "quote-aczco-fiscal", "source_system": "jasmin", "external_type": "jasmin_quotation", "title": "Orçamento Jasmin sem oportunidade · ORC.ORC2026.154", "customer_name": "ACZCO BRAGA ENERGY, LDA", "customer_tax_id": "517249200", "document_number": "ORC.ORC2026.154", "document_date": "2026-06-03", "amount": None, "currency": "EUR", "operation_suggestions": [], }, { "id": "sale-aczco-display-name", "source_system": "odoo", "external_type": "odoo_sale_order", "title": "Venda Odoo sem oportunidade · S00279", "customer_name": "ACZCO BRAGA ENERGY, LDA MERELIM SÃO PAIO Portugal", "customer_tax_id": "", "document_number": "S00279", "document_date": "2026-06-05", "amount": "638.60", "currency": "EUR", "payload": { "record": { "invoice_status": "to invoice", "fulfilment": { "invoice_pending": True, "lines": [{"product_name": "Wallbox 11KW"}], "outgoing_pickings": [{"name": "WH/OUT/00301", "state": "done", "date_done": "2026-06-05 15:12:00"}], }, } }, "operation_suggestions": [], }, ], ) candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10) assert len(candidates) == 1 candidate = candidates[0] assert candidate["match_key"] == "nif" assert candidate["match_value"] == "517249200" assert candidate["confidence"] == "alta" assert candidate["identity_reason"] == "NIF + nome fiscal" assert set(candidate["item_ids"]) == {"quote-aczco-fiscal", "sale-aczco-display-name"} assert candidate["suggested_stage"] == "SHIPPED" assert candidate["suggested_action"] == "SEND_INVOICE" assert any(step["external_type"] == "jasmin_quotation" for step in candidate["timeline"]) assert any(step["external_type"] == "odoo_sale_order" for step in candidate["timeline"]) assert any(step["external_type"] == "odoo_delivery" for step in candidate["timeline"])