Release v4928.1.4.2 stable
This commit is contained in:
13
tests/sample_cases/sample_analyze.json
Normal file
13
tests/sample_cases/sample_analyze.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"last_customer_message": "Boa tarde, gostávamos de avançar com a proposta. Pode enviar a fatura pró-forma?",
|
||||
"previous_context": "A empresa enviou proposta para 1 carregador monofásico 7,4 kW com cabo de 5 metros.",
|
||||
"current_state": {
|
||||
"conversation_state": "a_aguardar_cliente",
|
||||
"commercial_state": "proposta_enviada",
|
||||
"relationship_state": "lead_existente",
|
||||
"last_action_by": "empresa"
|
||||
},
|
||||
"source": "email",
|
||||
"conversation_id": "sample-001",
|
||||
"contact_id": "contact-001"
|
||||
}
|
||||
38
tests/test_clean_architecture_static.py
Normal file
38
tests/test_clean_architecture_static.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
LEGACY_ACTION_CODES = {
|
||||
"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT",
|
||||
"CHECK_SHIPMENT_STATUS",
|
||||
"CHECK_MISSING_MATERIAL",
|
||||
"HANDLE_TECHNICAL_SUPPORT",
|
||||
"HANDLE_WARRANTY_SUPPORT",
|
||||
"PREPARE_ORDER",
|
||||
"VALIDATE_SHIPMENT",
|
||||
"FOLLOW_UP_INFO",
|
||||
"CONTACT_CLIENT",
|
||||
"SCHEDULE_MEETING",
|
||||
}
|
||||
|
||||
|
||||
def test_action_catalog_contains_only_current_triage_codes():
|
||||
text = (ROOT / "app" / "action_catalog.py").read_text()
|
||||
for code in LEGACY_ACTION_CODES:
|
||||
assert code not in text
|
||||
|
||||
|
||||
def test_no_absolute_dashboard_paths():
|
||||
text = (ROOT / "app" / "admin_dashboard.py").read_text()
|
||||
assert "/mnt/ssd/home/plx/clientflow_backend" not in text
|
||||
assert "/mnt/ssd/backups/clientflow" not in text
|
||||
|
||||
|
||||
def test_task_preparations_schema_exists():
|
||||
text = (ROOT / "app" / "db.py").read_text()
|
||||
assert "CREATE TABLE IF NOT EXISTS task_preparations" in text
|
||||
|
||||
|
||||
def test_sqlite_default_removed():
|
||||
text = (ROOT / "app" / "config.py").read_text()
|
||||
assert "sqlite:///" not in text
|
||||
assert "database_url: str" in text
|
||||
20
tests/test_v40_operational_files.py
Normal file
20
tests/test_v40_operational_files.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_migrations_exist():
|
||||
assert (ROOT / "migrations" / "001_core_indexes.sql").exists()
|
||||
assert (ROOT / "scripts" / "apply_migrations.py").exists()
|
||||
|
||||
|
||||
def test_admin_ui_router_exists():
|
||||
assert (ROOT / "app" / "admin_ui" / "router.py").exists()
|
||||
main = (ROOT / "app" / "main.py").read_text()
|
||||
assert "app.admin_ui.router" in main
|
||||
|
||||
|
||||
def test_internal_api_exists():
|
||||
assert (ROOT / "app" / "api" / "internal.py").exists()
|
||||
main = (ROOT / "app" / "main.py").read_text()
|
||||
assert "internal_api_router" in main
|
||||
40
tests/test_v42_review_fixes.py
Normal file
40
tests/test_v42_review_fixes.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def test_outbox_dry_run_has_explicit_non_sent_state():
|
||||
text = (ROOT / "scripts" / "process_outbox.py").read_text()
|
||||
service = (ROOT / "app" / "integration_outbox_service.py").read_text()
|
||||
assert "mark_outbox_dry_run" in text
|
||||
assert 'status="dry_run"' in service
|
||||
assert "OUTBOX_DRY_RUN=true; ação não executada" in service
|
||||
|
||||
|
||||
def test_jasmin_quotation_idempotency_is_deterministic():
|
||||
text = (ROOT / "app" / "jasmin_service.py").read_text()
|
||||
assert 'idempotency_key=f"jasmin:quotation:{opportunity_id}"' in text
|
||||
assert 'idempotency_key=f"jasmin:quotation:{opportunity_id}:{uuid.uuid4()}"' not in text
|
||||
|
||||
|
||||
def test_admin_token_setting_is_wired():
|
||||
config = (ROOT / "app" / "config.py").read_text()
|
||||
dashboard = (ROOT / "app" / "admin_dashboard.py").read_text()
|
||||
assert "clientflow_admin_token" in config
|
||||
assert "require_admin_access" in dashboard
|
||||
assert "X-ClientFlow-Admin-Token" in dashboard
|
||||
|
||||
|
||||
def test_outbox_operational_states_are_visible():
|
||||
service = (ROOT / "app" / "integration_outbox_service.py").read_text()
|
||||
dashboard = (ROOT / "app" / "admin_dashboard.py").read_text()
|
||||
for state in ["blocked", "dry_run", "cancelled", "processing"]:
|
||||
assert state in service
|
||||
assert state in dashboard
|
||||
|
||||
|
||||
def test_internal_api_uses_admin_token_when_configured():
|
||||
text = (ROOT / "app" / "api" / "internal.py").read_text()
|
||||
assert "require_internal_access" in text
|
||||
assert "X-ClientFlow-Admin-Token" in text
|
||||
assert "dependencies=[Depends(require_internal_access)]" in text
|
||||
46
tests/test_v451_ui_alignment.py
Normal file
46
tests/test_v451_ui_alignment.py
Normal file
@@ -0,0 +1,46 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v453_ui_alignment_styles_are_present():
|
||||
admin = read("app/admin_dashboard.py")
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
layout = read("app/admin_ui/layout.py")
|
||||
navigation = read("app/admin_ui/navigation.py")
|
||||
components = read("app/admin_ui/components.py")
|
||||
assert "ADMIN_UI_V451_CSS" in admin
|
||||
assert "--cf-sidebar-width:284px" in styles
|
||||
assert "background:linear-gradient(180deg,#fff 0,#f8fbff 100%)" in styles
|
||||
assert "cf-global-search" in layout
|
||||
assert "v4.7 Operator Workbench" in navigation
|
||||
assert "cf-kpi-icon" in components
|
||||
assert "kpi_icon_for" in components
|
||||
|
||||
|
||||
def test_v452_nav_matches_operational_information_architecture():
|
||||
navigation = read("app/admin_ui/navigation.py")
|
||||
for label in [
|
||||
"Dashboard",
|
||||
"Centro de trabalho",
|
||||
"Oportunidades",
|
||||
"Clientes",
|
||||
"Produtos",
|
||||
"Encomendas",
|
||||
"Financeiro",
|
||||
"Integrações",
|
||||
"Admin",
|
||||
"Comunicações",
|
||||
"Outbox",
|
||||
"Configuração",
|
||||
]:
|
||||
assert label in navigation
|
||||
|
||||
|
||||
def test_ui_css_endpoint_uses_v451_css():
|
||||
admin = read("app/admin_dashboard.py")
|
||||
assert "return Response(ADMIN_UI_V451_CSS" in admin
|
||||
49
tests/test_v45_operational_core.py
Normal file
49
tests/test_v45_operational_core.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v45_communications_service_and_schema_exist():
|
||||
service = read("app/communication_service.py")
|
||||
db = read("app/db.py")
|
||||
migration = read("migrations/006_v45_operational_core.sql")
|
||||
|
||||
assert "CREATE TABLE IF NOT EXISTS communications" in service
|
||||
assert "CREATE TABLE IF NOT EXISTS timeline_events" in service
|
||||
assert "communication_id" in service
|
||||
assert "ensure_communication_schema" in db
|
||||
assert "communications" in migration
|
||||
assert "timeline_events" in migration
|
||||
|
||||
|
||||
def test_v45_admin_ui_routes_are_registered():
|
||||
communications = read("app/admin_ui/pages/communications.py")
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
dashboard = read("app/admin_ui/pages/dashboard.py")
|
||||
assert '@router.get("/communications"' in communications
|
||||
assert '@router.get("/comunicacoes"' in communications
|
||||
assert '@router.get("/communications/{communication_id}"' in communications
|
||||
assert 'Centro de trabalho' in operations
|
||||
assert 'Dashboard = visibilidade' in dashboard
|
||||
assert 'Inbox classificada' in communications
|
||||
|
||||
|
||||
def test_v45_operations_surface_work_items():
|
||||
operations = read("app/operations_service.py")
|
||||
operations_page = read("app/admin_ui/pages/operations.py")
|
||||
assert "work_items" in operations
|
||||
assert "recent_communications" in operations
|
||||
assert "communications_open" in operations
|
||||
assert "Fila operacional priorizada" in operations_page
|
||||
|
||||
|
||||
def test_v45_opportunity_has_communication_and_timeline_sections():
|
||||
opportunities = read("app/admin_ui/pages/opportunities.py")
|
||||
assert 'href="#comunicacoes"' in opportunities
|
||||
assert 'id="comunicacoes"' in opportunities
|
||||
assert 'id="timeline"' in opportunities
|
||||
assert 'list_communications_for_opportunity' in opportunities
|
||||
22
tests/test_v461_customer_mismatch_warning.py
Normal file
22
tests/test_v461_customer_mismatch_warning.py
Normal file
@@ -0,0 +1,22 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_customer_mismatch_warning_no_longer_blocks_by_name():
|
||||
admin = read("app/admin_dashboard.py")
|
||||
|
||||
assert "def opportunity_customer_mismatch" in admin
|
||||
assert "return False" in admin
|
||||
assert "Nome de contacto ≠ cliente fiscal não é um erro fiável" in admin
|
||||
assert "Atenção: contacto e cliente fiscal não coincidem" not in admin
|
||||
|
||||
|
||||
def test_customer_mismatch_setting_remains_accepted_for_env_compatibility():
|
||||
config = read("app/config.py")
|
||||
assert "clientflow_customer_mismatch_warning_enabled" in config
|
||||
assert "clientflow_customer_mismatch_warning_enabled: bool = False" in config
|
||||
27
tests/test_v462_disable_customer_mismatch_warning.py
Normal file
27
tests/test_v462_disable_customer_mismatch_warning.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_customer_mismatch_warning_disabled_by_default():
|
||||
config = read("app/config.py")
|
||||
assert "clientflow_customer_mismatch_warning_enabled: bool = False" in config
|
||||
assert "legitimate contacts use abbreviated names" in config
|
||||
|
||||
|
||||
def test_opportunity_detail_switches_to_single_column_earlier():
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
assert "@media (max-width: 1400px)" in styles
|
||||
assert ".detail-grid" in styles
|
||||
assert "grid-template-columns: 1fr !important" in styles
|
||||
|
||||
|
||||
def test_v462_notes_exist():
|
||||
notes = read("docs/CLIENTFLOW_V462_DISABLE_CUSTOMER_MISMATCH_WARNING.md")
|
||||
assert "CLIENTFLOW_CUSTOMER_MISMATCH_WARNING_ENABLED=false" in notes
|
||||
assert "Riotec elec" in notes
|
||||
assert "Bruno Oliveira" in notes
|
||||
36
tests/test_v462_mark_no_interest.py
Normal file
36
tests/test_v462_mark_no_interest.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_mark_no_interest_is_available_action():
|
||||
catalog = read("app/action_catalog.py")
|
||||
prompt = read("app/action_prompt.py")
|
||||
task_service = read("app/task_service.py")
|
||||
admin = read("app/admin_dashboard.py")
|
||||
|
||||
assert '"MARK_NO_INTEREST"' in catalog
|
||||
assert '"action": "Marcar sem interesse"' in catalog
|
||||
assert "não tem veículos elétricos" in prompt
|
||||
assert '"MARK_NO_INTEREST": ("vendas", "Marcar sem interesse")' in task_service
|
||||
assert '"MARK_NO_INTEREST": "Marcar sem interesse"' in admin
|
||||
|
||||
|
||||
def test_mark_no_interest_has_deterministic_rule():
|
||||
decider = read("app/action_decider.py")
|
||||
assert "detect_deterministic_action" in decider
|
||||
assert "MARK_NO_INTEREST" in decider
|
||||
assert "não temos" in decider or "n[aã]o" in decider
|
||||
assert "deterministic-rule" in decider
|
||||
|
||||
|
||||
def test_no_interest_closes_existing_opportunity_only_when_task_done():
|
||||
opp = read("app/opportunity_service.py")
|
||||
assert '"MARK_NO_INTEREST",' in opp
|
||||
assert '"MARK_NO_INTEREST": "REVIEW"' in opp
|
||||
assert '"MARK_NO_INTEREST": "NO_INTEREST"' in opp
|
||||
assert 'if action_code == "MARK_NO_INTEREST" and not existing' in opp
|
||||
43
tests/test_v463_operations_queue_cleanup.py
Normal file
43
tests/test_v463_operations_queue_cleanup.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_no_interest_is_distinct_from_lost_opportunity():
|
||||
opp = read("app/opportunity_service.py")
|
||||
admin = read("app/admin_dashboard.py")
|
||||
|
||||
assert '"NO_INTEREST": "Sem interesse"' in opp
|
||||
assert '"MARK_NO_INTEREST": "NO_INTEREST"' in opp
|
||||
assert '"NO_INTEREST": 118' in opp
|
||||
assert '("closed", "Fechadas", ["WON", "LOST", "NO_INTEREST"])' in opp
|
||||
assert '"NO_INTEREST": "Sem interesse"' in admin
|
||||
assert 'não é o mesmo que oportunidade perdida' in admin
|
||||
|
||||
|
||||
def test_operations_queue_filters_manually_cleaned_outbox_noise():
|
||||
ops = read("app/operations_service.py")
|
||||
assert "limpo manualmente" in ops
|
||||
assert "resolvido manualmente" in ops
|
||||
assert "_is_manually_resolved_error" in ops
|
||||
assert "COALESCE(last_error,'') NOT ILIKE '%limpo manualmente%'" in ops
|
||||
assert "Classificação da mensagem falhou" in ops
|
||||
|
||||
|
||||
def test_operations_labels_are_not_inbox_labels():
|
||||
operations_page = read("app/admin_ui/pages/operations.py")
|
||||
assert "Mensagens para revisão" in operations_page
|
||||
assert "Mensagens sem cliente" in operations_page
|
||||
assert "Emails por tratar" not in operations_page
|
||||
assert "Sem cliente', counts.get('communications_without_customer'" not in operations_page
|
||||
|
||||
|
||||
def test_ignore_manually_cleaned_outbox_script_exists():
|
||||
script = read("scripts/ignore_manually_cleaned_outbox.py")
|
||||
assert "limpo manualmente" in script
|
||||
assert "status = 'ignored'" in script
|
||||
assert "--dry-run" in script
|
||||
38
tests/test_v465_operations_work_queue.py
Normal file
38
tests/test_v465_operations_work_queue.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_operations_is_single_work_queue_not_mini_dashboard():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
assert "Lista única de trabalho do operador" in operations
|
||||
assert "o que tenho de fazer agora?" in operations
|
||||
assert "Não é um mini-dashboard técnico" in operations
|
||||
assert "Outbox que precisa de atenção" not in operations
|
||||
assert "Documentos que podem exigir ação" not in operations
|
||||
assert "Produtos sem Artigo Jasmin" not in operations
|
||||
assert "Clientes incompletos" not in operations
|
||||
|
||||
|
||||
def test_operations_has_operator_filters_and_primary_buttons():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
for label in ["Todas", "Vendas", "Financeiro", "Logística", "Revisão", "Bloqueadas", "Concluídas hoje"]:
|
||||
assert label in operations
|
||||
for label in ["Preparar orçamento", "Preparar pró-forma", "Confirmar pagamento", "Rever mensagem", "Associar cliente"]:
|
||||
assert label in operations
|
||||
assert "Abrir Chatwoot ↗" in operations
|
||||
assert "Ver oportunidade" in operations
|
||||
|
||||
|
||||
def test_operations_service_returns_compact_queue_counters_and_action_codes():
|
||||
ops = read("app/operations_service.py")
|
||||
assert "work_queue_total" in ops
|
||||
assert "overdue_tasks" in ops
|
||||
assert "review_tasks" in ops
|
||||
assert "blocked_outbox" in ops
|
||||
assert "t.action_code" in ops and "AS action_code" in ops
|
||||
assert "upper(io.target_system || '_' || io.action_type) AS action_code" in ops
|
||||
47
tests/test_v466_safe_opportunity_linking.py
Normal file
47
tests/test_v466_safe_opportunity_linking.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_opportunity_linking_uses_conversation_as_strong_match_and_contact_as_weak_match():
|
||||
service = read("app/opportunity_service.py")
|
||||
assert "_find_open_opportunity_by_conversation" in service
|
||||
assert "conversation_id = :conversation_id" in service
|
||||
assert "contact_id_unique_recent" in service
|
||||
assert "CONTACT_MATCH_RECENT_DAYS = 45" in service
|
||||
assert "len(_open_opportunities_for_contact(task.get(\"contact_id\"), limit=2)) > 1" in service
|
||||
assert "find_open_opportunity_for_task" in service
|
||||
old_risky_pattern = "AND ({' OR '.join(filters)})"
|
||||
assert old_risky_pattern not in service
|
||||
|
||||
|
||||
def test_ambiguous_contact_match_does_not_create_new_opportunity_and_marks_task_for_review():
|
||||
service = read("app/opportunity_service.py")
|
||||
assert "has_ambiguous_opportunity_match(task)" in service
|
||||
assert "mark_task_opportunity_link_ambiguous" in service
|
||||
assert "multiple_recent_open_opportunities_for_chatwoot_contact" in service
|
||||
assert "return None" in service
|
||||
|
||||
|
||||
def test_task_detail_does_not_link_chatwoot_contact_id_as_local_customer():
|
||||
tasks_page = read("app/admin_ui/pages/tasks.py")
|
||||
task_service = read("app/task_service.py")
|
||||
assert "linked_customer_id" in task_service
|
||||
assert "LEFT JOIN customers cu ON cu.id = o.local_customer_id" in task_service
|
||||
assert "safe_customer_id" in tasks_page
|
||||
assert "Ver cliente fiscal" in tasks_page
|
||||
assert 'href="/customers/{esc(contact_id)}"' not in tasks_page
|
||||
assert "Contacto Chatwoot" in tasks_page
|
||||
|
||||
|
||||
def test_operations_surfaces_ambiguous_opportunity_linking_as_operator_work():
|
||||
ops = read("app/operations_service.py")
|
||||
operations_page = read("app/admin_ui/pages/operations.py")
|
||||
assert "opportunity_linking_status" in ops
|
||||
assert "ASSOCIATE_OPPORTUNITY" in ops
|
||||
assert "Confirmar associação da oportunidade" in ops
|
||||
assert "Associar oportunidade" in operations_page
|
||||
40
tests/test_v467_chatwoot_autocomplete_guardrails.py
Normal file
40
tests/test_v467_chatwoot_autocomplete_guardrails.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_chatwoot_auto_complete_excludes_payment_and_review_by_default():
|
||||
service = read("app/task_service.py")
|
||||
start = service.index("# Guardrail v4.6.7")
|
||||
end = service.index('configured = os.getenv("CHATWOOT_AUTO_COMPLETE_ACTION_CODES"', start)
|
||||
defaults = service[start:end]
|
||||
assert '"SEND_INFO"' in defaults
|
||||
assert '"SEND_QUOTE"' in defaults
|
||||
assert '"SUPPORT"' in defaults
|
||||
assert '"CONFIRM_PAYMENT"' not in defaults
|
||||
assert '"MARK_NO_INTEREST"' not in defaults
|
||||
assert '"REVIEW_MANUALLY"' not in defaults
|
||||
|
||||
|
||||
def test_chatwoot_auto_complete_blocks_ambiguous_opportunity_linking():
|
||||
service = read("app/task_service.py")
|
||||
assert "blocked_ambiguous_opportunity" in service
|
||||
assert "operator_must_confirm_opportunity_before_auto_complete" in service
|
||||
assert "opportunity_linking_status" in service
|
||||
|
||||
|
||||
def test_task_done_records_timeline_event_after_completion():
|
||||
service = read("app/task_service.py")
|
||||
assert "task_auto_completed" in service
|
||||
assert "Task concluída automaticamente" in service
|
||||
assert "_record_task_timeline_event(task_id, event_type=timeline_event_type)" in service
|
||||
|
||||
|
||||
def test_v467_documentation_exists():
|
||||
doc = read("docs/CLIENTFLOW_V467_CHATWOOT_AUTOCOMPLETE_GUARDRAILS.md")
|
||||
assert "Chatwoot Auto-complete Guardrails" in doc
|
||||
assert "CONFIRM_PAYMENT" in doc
|
||||
60
tests/test_v468_operational_safety_consolidation.py
Normal file
60
tests/test_v468_operational_safety_consolidation.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_auto_complete_has_hard_policy_deny_list_even_when_env_is_configured():
|
||||
service = read("app/task_service.py")
|
||||
assert "never_auto_complete_codes" in service
|
||||
assert '"CONFIRM_PAYMENT"' in service
|
||||
assert '"CONFIRM_PAYMENT_AND_PREPARE_SHIPMENT"' in service
|
||||
assert '"REMOVE_FROM_LIST"' in service
|
||||
assert "blocked_by_policy" in service
|
||||
assert "ClientFlow auto-complete policy ignored action_codes" in service
|
||||
|
||||
|
||||
def test_outbox_worker_claims_rows_with_skip_locked_instead_of_listing_pending():
|
||||
service = read("app/integration_outbox_service.py")
|
||||
script = read("scripts/process_outbox.py")
|
||||
assert "def claim_pending_outbox" in service
|
||||
assert "FOR UPDATE SKIP LOCKED" in service
|
||||
assert "status = 'processing'" in service
|
||||
assert "locked_at = now()" in service
|
||||
assert "lock_owner = :lock_owner" in service
|
||||
assert "claim_pending_outbox" in script
|
||||
assert "list_pending_outbox" not in script
|
||||
assert "OUTBOX_WORKER_ID" in script
|
||||
|
||||
|
||||
def test_outbox_completion_releases_processing_lock():
|
||||
service = read("app/integration_outbox_service.py")
|
||||
assert "def mark_outbox_sent" in service
|
||||
assert "locked_at = NULL" in service
|
||||
assert "lock_owner = NULL" in service
|
||||
assert "def mark_outbox_failed" in service
|
||||
|
||||
|
||||
def test_system_health_exposes_operational_metrics_and_autocomplete_config():
|
||||
ops = read("app/operations_service.py")
|
||||
assert "operational_metrics" in ops
|
||||
assert "tasks_auto_completed_24h" in ops
|
||||
assert "ambiguous_opportunity_tasks" in ops
|
||||
assert "outbox_processing_stale" in ops
|
||||
assert "chatwoot_auto_complete_action_codes" in ops
|
||||
|
||||
|
||||
def test_tasks_route_still_exists_but_is_not_primary_navigation():
|
||||
operations_page = read("app/admin_ui/pages/operations.py")
|
||||
tasks_page = read("app/admin_ui/pages/tasks.py")
|
||||
navigation = read("app/admin_ui/navigation.py")
|
||||
primary_start = navigation.index("PRIMARY_NAV_ITEMS")
|
||||
primary_end = navigation.index("ADMIN_NAV_ITEMS", primary_start)
|
||||
primary_nav = navigation[primary_start:primary_end]
|
||||
assert 'NavItem("tasks", "/tasks"' not in primary_nav
|
||||
assert 'NavItem("tasks", "/tasks", "bi-list-check", "Tasks", "admin")' in navigation
|
||||
assert '@router.get("/tasks", response_class=HTMLResponse)' in tasks_page
|
||||
assert "Ver lista completa de tarefas" in operations_page
|
||||
53
tests/test_v46_chatwoot_workflow.py
Normal file
53
tests/test_v46_chatwoot_workflow.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_review_manually_and_remove_from_list_are_operational_pending_actions():
|
||||
task_service = read("app/task_service.py")
|
||||
action_catalog = read("app/action_catalog.py")
|
||||
|
||||
assert "def _initial_task_status" in task_service
|
||||
assert 'code in {"IGNORE_SPAM", "NO_ACTION"}' in task_service
|
||||
assert 'code in {"REVIEW_MANUALLY", "REMOVE_FROM_LIST"}' in task_service
|
||||
assert '"REVIEW_MANUALLY": {\n "route": "rever",\n "action_required": True' in action_catalog
|
||||
assert '"REMOVE_FROM_LIST": {\n "route": "marketing",\n "action_required": True' in action_catalog
|
||||
|
||||
|
||||
def test_reclassify_review_stays_pending_and_spam_is_skipped():
|
||||
task_service = read("app/task_service.py")
|
||||
assert 'new_status = "skipped" if new_action_code in {"IGNORE_SPAM", "NO_ACTION"} else "pending"' in task_service
|
||||
assert '"REMOVE_FROM_LIST": ("marketing", "Remover contacto da lista")' in task_service
|
||||
|
||||
|
||||
def test_operations_work_items_include_chatwoot_context_and_url():
|
||||
operations = read("app/operations_service.py")
|
||||
operations_page = read("app/admin_ui/pages/operations.py")
|
||||
|
||||
assert "build_chatwoot_conversation_url" in operations
|
||||
assert "chatwoot_url" in operations
|
||||
assert "t.source_system" in operations
|
||||
assert "t.conversation_id" in operations
|
||||
assert "Chatwoot ↗" in operations_page
|
||||
assert "O Chatwoot é a inbox" in operations_page
|
||||
|
||||
|
||||
def test_communications_not_primary_navigation_but_routes_stay_available():
|
||||
navigation = read("app/admin_ui/navigation.py")
|
||||
communications = read("app/admin_ui/pages/communications.py")
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
assert '("communications", "/communications", "bi-envelope", "Comunicações")' not in navigation
|
||||
assert '@router.get("/communications"' in communications
|
||||
assert 'Chatwoot continua a ser a inbox' in communications or 'O Chatwoot é a inbox' in operations
|
||||
|
||||
|
||||
def test_reopen_chatwoot_review_tasks_script_exists():
|
||||
script = read("scripts/reopen_chatwoot_review_tasks.py")
|
||||
assert "REVIEW_MANUALLY" in script
|
||||
assert "REMOVE_FROM_LIST" in script
|
||||
assert "--dry-run" in script
|
||||
assert "status = 'pending'" in script
|
||||
72
tests/test_v472_domain_routes.py
Normal file
72
tests/test_v472_domain_routes.py
Normal file
@@ -0,0 +1,72 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v472_page_modules_exist():
|
||||
for module in [
|
||||
"dashboard",
|
||||
"operations",
|
||||
"opportunities",
|
||||
"tasks",
|
||||
"customers",
|
||||
"products",
|
||||
"orders",
|
||||
"finance",
|
||||
"integrations",
|
||||
"communications",
|
||||
"outbox",
|
||||
"events",
|
||||
"runs",
|
||||
"queues",
|
||||
"conversations",
|
||||
"system",
|
||||
]:
|
||||
path = ROOT / "app" / "admin_ui" / "pages" / f"{module}.py"
|
||||
assert path.exists(), f"missing {path}"
|
||||
content = path.read_text(encoding="utf-8")
|
||||
assert "router = APIRouter()" in content
|
||||
|
||||
|
||||
def test_v472_router_aggregates_domain_modules():
|
||||
router = read("app/admin_ui/router.py")
|
||||
assert "route registration is split by domain" in router.lower()
|
||||
for module in [
|
||||
"dashboard", "operations", "opportunities", "tasks",
|
||||
"customers", "products", "orders", "finance", "integrations",
|
||||
"communications", "outbox", "events", "runs", "queues", "system",
|
||||
]:
|
||||
assert f"from app.admin_ui.pages import {module}" in router
|
||||
assert f"router.include_router({module}.router)" in router
|
||||
|
||||
|
||||
def test_v472_routes_are_no_longer_registered_in_admin_dashboard():
|
||||
dashboard = read("app/admin_dashboard.py")
|
||||
assert "Route handlers moved to app.admin_ui.pages.* in v4.7.2" in dashboard
|
||||
assert dashboard.count("@router.get(") <= 1 # /ui.css compatibility endpoint only
|
||||
assert "@router.get(\"/operations\"" not in dashboard
|
||||
assert "@router.get(\"/tasks\"" not in dashboard
|
||||
assert "@router.get(\"/opportunities\"" not in dashboard
|
||||
admin_lines = dashboard.count("\n") + 1
|
||||
assert admin_lines < 1800
|
||||
|
||||
|
||||
def test_v472_domain_modules_own_expected_routes():
|
||||
assert "@router.get(\"/operations\"" in read("app/admin_ui/pages/operations.py")
|
||||
assert "@router.get(\"/operacoes\"" in read("app/admin_ui/pages/operations.py")
|
||||
assert "@router.get(\"/opportunities\"" in read("app/admin_ui/pages/opportunities.py")
|
||||
assert "@router.post(\"/opportunities/{opportunity_id}/stage\"" in read("app/admin_ui/pages/opportunities.py")
|
||||
assert "@router.get(\"/tasks\"" in read("app/admin_ui/pages/tasks.py")
|
||||
assert "@router.post(\"/tasks/{task_id}/complete\"" in read("app/admin_ui/pages/tasks.py")
|
||||
assert "@router.get(\"/outbox\"" in read("app/admin_ui/pages/outbox.py")
|
||||
assert "@router.get(\"/system/health\"" in read("app/admin_ui/pages/system.py")
|
||||
|
||||
|
||||
def test_v472_documentation_exists_and_states_no_db_migration():
|
||||
doc = read("docs/CLIENTFLOW_V472_DOMAIN_ROUTES.md")
|
||||
assert "Rotas por domínio" in doc
|
||||
assert "Não requer migração de base de dados" in doc
|
||||
54
tests/test_v473_htmx_partials.py
Normal file
54
tests/test_v473_htmx_partials.py
Normal file
@@ -0,0 +1,54 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v473_htmx_helpers_and_labels_exist():
|
||||
assert (ROOT / "app/admin_ui/labels.py").exists()
|
||||
assert (ROOT / "app/admin_ui/htmx.py").exists()
|
||||
assert (ROOT / "app/admin_ui/view_models/operations.py").exists()
|
||||
labels = read("app/admin_ui/labels.py")
|
||||
assert "ACTION_LABELS" in labels
|
||||
assert '"CONFIRM_PAYMENT": "Confirmar pagamento"' in labels
|
||||
assert '"ASSOCIATE_OPPORTUNITY": "Associar oportunidade"' in labels
|
||||
|
||||
|
||||
def test_v473_operations_has_htmx_work_items_partial():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
vm = read("app/admin_ui/view_models/operations.py")
|
||||
assert '@router.get("/operations/partials/work-items"' in operations
|
||||
assert 'id="operations-work-items"' in operations
|
||||
assert 'hx-get="{esc(partial_href)}"' in operations
|
||||
assert 'hx-target="#operations-work-items"' in operations
|
||||
assert 'hx-push-url' in operations
|
||||
assert "build_operations_view_model" in vm
|
||||
assert "operation_item_matches_scope" in vm
|
||||
|
||||
|
||||
def test_v473_tasks_has_htmx_list_partial():
|
||||
tasks = read("app/admin_ui/pages/tasks.py")
|
||||
assert '@router.get("/tasks/partials/list"' in tasks
|
||||
assert 'id="tasks-list"' in tasks
|
||||
assert 'hx-get="/tasks/partials/list' in tasks
|
||||
assert 'hx-target="#tasks-list"' in tasks
|
||||
assert 'hx-push-url' in tasks
|
||||
|
||||
|
||||
def test_v473_existing_opportunity_partials_remain_available():
|
||||
opportunities = read("app/admin_ui/pages/opportunities.py")
|
||||
assert '@router.get("/opportunities/{opportunity_id}/partials/jasmin-documents"' in opportunities
|
||||
assert '@router.get("/opportunities/{opportunity_id}/partials/products"' in opportunities
|
||||
assert 'hx-target="#jasmin-documents-panel"' in read("app/admin_dashboard.py")
|
||||
assert 'hx-target="#opportunity-products-panel"' in read("app/admin_dashboard.py")
|
||||
|
||||
|
||||
def test_v473_documentation_states_no_db_or_workflow_change():
|
||||
doc = read("docs/CLIENTFLOW_V473_HTMX_PARTIALS.md")
|
||||
assert "Não altera regras de negócio" in doc
|
||||
assert "Não requer migração" not in doc or "base de dados" in doc
|
||||
assert "/operations/partials/work-items" in doc
|
||||
assert "/tasks/partials/list" in doc
|
||||
51
tests/test_v474_htmx_completion.py
Normal file
51
tests/test_v474_htmx_completion.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v474_opportunities_board_partial_exists_and_is_htmx_targeted():
|
||||
opportunities = read("app/admin_ui/pages/opportunities.py")
|
||||
assert '@router.get("/opportunities/partials/board"' in opportunities
|
||||
assert 'id="opportunities-board"' in opportunities
|
||||
assert 'hx-get="/opportunities/partials/board"' in opportunities
|
||||
assert 'hx-target="#opportunities-board"' in opportunities
|
||||
assert 'hx-swap="outerHTML"' in opportunities
|
||||
assert 'hx-push-url' in opportunities
|
||||
for label in ["Novas", "Orçamento enviado", "Pró-forma enviada", "Pagamento pendente", "Bloqueadas"]:
|
||||
assert label in opportunities
|
||||
|
||||
|
||||
def test_v474_task_detail_partial_and_actions_are_htmx_targeted():
|
||||
tasks = read("app/admin_ui/pages/tasks.py")
|
||||
assert '@router.get("/tasks/{task_id}/partials/detail"' in tasks
|
||||
assert 'id="task-detail-panel"' in tasks
|
||||
assert 'hx-target="#task-detail-panel"' in tasks
|
||||
assert 'hx-swap="outerHTML"' in tasks
|
||||
assert 'hx-post="/tasks/{esc(task_id)}/complete-with-note"' in tasks
|
||||
assert 'hx-post="/tasks/{esc(task_id)}/skip"' in tasks
|
||||
assert 'render_task_detail_partial(task_id, notice="Tarefa concluída.")' in tasks
|
||||
|
||||
|
||||
def test_v474_outbox_table_partial_and_actions_are_htmx_targeted():
|
||||
outbox = read("app/admin_ui/pages/outbox.py")
|
||||
assert '@router.get("/outbox/partials/table"' in outbox
|
||||
assert 'id="outbox-table"' in outbox
|
||||
assert 'hx-get="{_outbox_partial_url' in outbox
|
||||
assert 'hx-target="#outbox-table"' in outbox
|
||||
assert 'hx-swap="outerHTML"' in outbox
|
||||
assert 'hx-post="/outbox/{oid}/pending"' in outbox
|
||||
assert 'hx-post="/outbox/{oid}/ignored"' in outbox
|
||||
|
||||
|
||||
def test_v474_documentation_states_no_db_or_workflow_change():
|
||||
doc = read("docs/CLIENTFLOW_V474_HTMX_COMPLETION.md")
|
||||
assert "HTMX Completion" in doc
|
||||
assert "/opportunities/partials/board" in doc
|
||||
assert "/tasks/{task_id}/partials/detail" in doc
|
||||
assert "/outbox/partials/table" in doc
|
||||
assert "Não requer migração de base de dados" in doc
|
||||
assert "Não altera auto-complete" in doc
|
||||
63
tests/test_v47_admin_ui_refactor.py
Normal file
63
tests/test_v47_admin_ui_refactor.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v47_admin_ui_modules_exist_and_dashboard_is_smaller():
|
||||
assert (ROOT / "app/admin_ui/styles.py").exists()
|
||||
assert (ROOT / "app/admin_ui/navigation.py").exists()
|
||||
assert (ROOT / "app/admin_ui/layout.py").exists()
|
||||
admin_lines = read("app/admin_dashboard.py").count("\n") + 1
|
||||
assert admin_lines < 4500
|
||||
assert "ADMIN_UI_CSS moved to app.admin_ui.styles" in read("app/admin_dashboard.py")
|
||||
|
||||
|
||||
def test_v47_primary_navigation_is_operator_first_and_admin_is_separate():
|
||||
navigation = read("app/admin_ui/navigation.py")
|
||||
primary = navigation[navigation.index("PRIMARY_NAV_ITEMS"):navigation.index("ADMIN_NAV_ITEMS")]
|
||||
admin = navigation[navigation.index("ADMIN_NAV_ITEMS"):]
|
||||
for label in ["Dashboard", "Centro de trabalho", "Oportunidades", "Clientes", "Produtos", "Encomendas", "Financeiro", "Integrações"]:
|
||||
assert label in primary
|
||||
for technical in ["Tasks", "Comunicações", "Outbox", "Eventos", "Runs", "Filas", "System health"]:
|
||||
assert technical not in primary
|
||||
assert technical in admin
|
||||
assert "v4.7 Operator Workbench" in navigation
|
||||
|
||||
|
||||
def test_v47_layout_uses_shared_navigation_and_css_endpoint_keeps_compatibility():
|
||||
layout = read("app/admin_ui/layout.py")
|
||||
dashboard = read("app/admin_dashboard.py")
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
assert "from app.admin_ui.navigation import nav" in layout
|
||||
assert "from app.admin_ui.styles import ADMIN_UI_V451_CSS" in layout
|
||||
assert "return Response(ADMIN_UI_V451_CSS" in dashboard
|
||||
assert "v4.7 admin navigation" in styles
|
||||
|
||||
|
||||
def test_v47_opportunities_board_has_direct_pending_task_cta():
|
||||
opportunities = read("app/admin_ui/pages/opportunities.py")
|
||||
assert 'cta_label = "Concluir tarefa pendente" if pending else "Ver oportunidade"' in opportunities
|
||||
assert "btn-primary" in opportunities
|
||||
assert "Pipeline comercial com foco na próxima ação" in opportunities
|
||||
|
||||
|
||||
def test_v47_documentation_describes_operator_model_and_no_db_migration():
|
||||
doc = read("docs/CLIENTFLOW_V47_ADMIN_UI_REFACTOR.md")
|
||||
assert "Chatwoot" in doc and "Operations" in doc
|
||||
assert "Admin" in doc and "Outbox" in doc
|
||||
assert "não requer migração de base de dados" in doc
|
||||
|
||||
|
||||
def test_v471_sidebar_admin_menu_can_scroll_when_open_on_short_screens():
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
assert "v4.7.1 admin navigation overflow fix" in styles
|
||||
assert ".cf-sidebar" in styles
|
||||
assert "overflow-y:auto" in styles
|
||||
assert "max-height:100vh" in styles
|
||||
assert "overscroll-behavior:contain" in styles
|
||||
assert "scrollbar-gutter:stable" in styles
|
||||
assert "overflow-y:hidden" in styles # mobile horizontal nav keeps old behavior
|
||||
13
tests/test_v4801_admin_menu_collapsed.py
Normal file
13
tests/test_v4801_admin_menu_collapsed.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from app.admin_ui.navigation import nav
|
||||
|
||||
|
||||
def test_v4801_admin_menu_is_collapsed_by_default_on_primary_pages():
|
||||
html = nav("operations")
|
||||
assert '<details class="cf-admin-nav">' in html
|
||||
assert '<details class="cf-admin-nav" open>' not in html
|
||||
|
||||
|
||||
def test_v4801_admin_menu_opens_when_current_page_is_admin_area():
|
||||
html = nav("outbox")
|
||||
assert '<details class="cf-admin-nav" open>' in html
|
||||
assert '<summary class="cf-nav-link active">' in html
|
||||
68
tests/test_v480_operational_automation_audit_recovery.py
Normal file
68
tests/test_v480_operational_automation_audit_recovery.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v480_outbox_stale_recovery_service_and_worker_are_present():
|
||||
service = read("app/integration_outbox_service.py")
|
||||
worker = read("scripts/process_outbox.py")
|
||||
script = read("scripts/recover_stale_outbox.py")
|
||||
assert "def recover_stale_processing_outbox" in service
|
||||
assert "OUTBOX_STALE_PROCESSING_MINUTES" in service
|
||||
assert "OUTBOX_STALE_RECOVERY_MODE" in service
|
||||
assert "manual_only" in service
|
||||
assert "retry_pending" in service
|
||||
assert "mark_failed" in service
|
||||
assert "recover_stale_processing_outbox" in worker
|
||||
assert "OUTBOX_RECOVER_STALE_BEFORE_PROCESS" in worker
|
||||
assert "recover_stale_processing_outbox" in script
|
||||
|
||||
|
||||
def test_v480_operator_audit_service_and_sensitive_actions_are_audited():
|
||||
audit = read("app/operator_audit_service.py")
|
||||
tasks = read("app/task_service.py")
|
||||
outbox_page = read("app/admin_ui/pages/outbox.py")
|
||||
assert "def record_operator_action" in audit
|
||||
assert "event_type = 'operator_action'" in audit
|
||||
assert "task_completed" in tasks
|
||||
assert "task_skipped" in tasks
|
||||
assert "task_reclassified" in tasks
|
||||
assert "outbox_reprocess_requested" in outbox_page
|
||||
assert "outbox_marked_failed" in outbox_page
|
||||
assert "outbox_ignored" in outbox_page
|
||||
|
||||
|
||||
def test_v480_sensitive_actions_have_htmx_confirmation():
|
||||
tasks_page = read("app/admin_ui/pages/tasks.py")
|
||||
outbox_page = read("app/admin_ui/pages/outbox.py")
|
||||
assert "hx-confirm=\"Confirmar conclusão desta tarefa?\"" in tasks_page
|
||||
assert "hx-confirm=\"Ignorar esta tarefa?" in tasks_page
|
||||
assert "hx-confirm=\"Guardar reclassificação manual desta tarefa?\"" in tasks_page
|
||||
assert "hx-confirm=\"Reprocessar este item da outbox?\"" in outbox_page
|
||||
assert "hx-confirm=\"Ignorar este item da outbox?\"" in outbox_page
|
||||
assert "hx-confirm=\"Marcar este item como failed?\"" in outbox_page
|
||||
|
||||
|
||||
def test_v480_operational_health_and_operations_filters_are_expanded():
|
||||
ops = read("app/admin_ui/view_models/operations.py")
|
||||
service = read("app/operations_service.py")
|
||||
system = read("app/admin_ui/pages/system.py")
|
||||
assert "Ambíguas" in ops
|
||||
assert "Atrasadas" in ops
|
||||
assert "is_overdue" in ops
|
||||
assert "operator_actions_24h" in service
|
||||
assert "outbox_stale" in service
|
||||
assert "Outbox stale" in system
|
||||
assert "Ações operador 24h" in system
|
||||
|
||||
|
||||
def test_v480_documentation_exists():
|
||||
doc = read("docs/CLIENTFLOW_V480_OPERATIONAL_AUTOMATION_AUDIT_RECOVERY.md")
|
||||
assert "Operational Automation, Audit & Recovery" in doc
|
||||
assert "OUTBOX_STALE_PROCESSING_MINUTES" in doc
|
||||
assert "operator_action" in doc
|
||||
assert "hx-confirm" in doc
|
||||
58
tests/test_v482_production_stabilization.py
Normal file
58
tests/test_v482_production_stabilization.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v482_guided_operations_helpers_exist():
|
||||
guidance = read("app/admin_ui/guidance.py")
|
||||
assert "def fiscal_contact_panel_html" in guidance
|
||||
assert "Cliente fiscal e contacto Chatwoot" in guidance
|
||||
assert "def readiness_checklist_html" in guidance
|
||||
assert "Prontidão" in guidance
|
||||
assert "def outbox_operator_message" in guidance
|
||||
assert "Motivo provável" in read("app/admin_ui/pages/outbox.py")
|
||||
|
||||
|
||||
def test_v482_operations_and_opportunities_show_fiscal_context_and_blockers():
|
||||
ops = read("app/admin_ui/pages/operations.py")
|
||||
opps = read("app/admin_ui/pages/opportunities.py")
|
||||
vm = read("app/admin_ui/view_models/operations.py")
|
||||
assert "work_item_blockers" in ops
|
||||
assert "Associações por confirmar" in vm
|
||||
assert "Bloqueios atuais" in opps
|
||||
assert "Prontidão para documentos" in opps
|
||||
assert "Prontidão para envio" in opps
|
||||
assert "Associação fiscal" in opps
|
||||
|
||||
|
||||
def test_v482_task_detail_separates_fiscal_customer_and_contact():
|
||||
tasks = read("app/admin_ui/pages/tasks.py")
|
||||
assert "fiscal_contact_panel_html" in tasks
|
||||
assert "Prontidão fiscal da tarefa" in tasks
|
||||
assert "Prontidão mínima antes de documento/envio" in tasks
|
||||
assert "Corrigir estes dados antes de emitir documento" in tasks
|
||||
|
||||
|
||||
def test_v482_system_health_has_production_state_and_data_quality_metrics():
|
||||
service = read("app/operations_service.py")
|
||||
system = read("app/admin_ui/pages/system.py")
|
||||
assert "open_opportunities_without_fiscal_customer" in service
|
||||
assert "active_incomplete_fiscal_customers" in service
|
||||
assert "products_missing_external_code" in service
|
||||
assert "seconds_since_last_chatwoot_webhook" in service
|
||||
assert "Estado para operação" in system
|
||||
assert "Oportunidades sem cliente fiscal" in system
|
||||
assert "Clientes fiscais incompletos" in system
|
||||
assert "Último webhook Chatwoot" in system
|
||||
|
||||
|
||||
def test_v482_documentation_exists():
|
||||
doc = read("docs/CLIENTFLOW_V482_PRODUCTION_STABILIZATION.md")
|
||||
assert "Production Stabilization" in doc
|
||||
assert "Cliente fiscal" in doc
|
||||
assert "Outbox em linguagem operacional" in doc
|
||||
assert "Sem migração" in doc
|
||||
63
tests/test_v483_operations_guardrails.py
Normal file
63
tests/test_v483_operations_guardrails.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v483_opportunity_creation_guardrails_exist():
|
||||
service = read("app/opportunity_service.py")
|
||||
assert "OPPORTUNITY_CREATE_ACTION_CODES" in service
|
||||
assert "NEVER_CREATE_OPPORTUNITY_ACTION_CODES" in service
|
||||
assert "SYSTEM_SENDER_PATTERNS" in service
|
||||
assert "Mail Delivery" not in service # patterns are normalized lower-case
|
||||
assert "mail delivery subsystem" in service
|
||||
assert "returned mail" in service
|
||||
assert "def can_create_new_opportunity_for_task" in service
|
||||
assert "system_or_bounce_message" in service
|
||||
assert "send_info_without_clear_commercial_intent" in service
|
||||
|
||||
|
||||
def test_v483_operations_card_is_compact_and_does_not_use_contact_as_fiscal_customer():
|
||||
ops_page = read("app/admin_ui/pages/operations.py")
|
||||
ops_service = read("app/operations_service.py")
|
||||
vm = read("app/admin_ui/view_models/operations.py")
|
||||
guidance = read("app/admin_ui/guidance.py")
|
||||
assert "operation_card_title" in ops_page
|
||||
assert "operation_card_subtitle" in ops_page
|
||||
assert "Ver detalhes" not in ops_page
|
||||
assert "work_item_blockers" in ops_page
|
||||
assert "Cliente fiscal" in guidance and "show_fiscal" in guidance
|
||||
assert "COALESCE(cu.name, t.customer_id, t.contact_id, '') AS customer_name" not in ops_service
|
||||
assert "fiscal_customer_name" in ops_service
|
||||
assert "contact_display_name" in ops_service
|
||||
assert "_is_numeric_reference" in vm
|
||||
assert "Contacto Chatwoot #" in vm
|
||||
|
||||
|
||||
def test_v483_blockers_are_journey_aware():
|
||||
guidance = read("app/admin_ui/guidance.py")
|
||||
assert "DOCUMENT_ACTION_CODES" in guidance
|
||||
assert "stage_requires_fiscal_customer" in guidance
|
||||
assert "Missing fiscal data is not automatically a blocker at the first contact" in guidance
|
||||
assert "def work_item_blockers" in guidance
|
||||
assert "item_requires_fiscal_customer" in guidance
|
||||
|
||||
|
||||
def test_v483_priority_demotes_review_marketing_noise():
|
||||
vm = read("app/admin_ui/view_models/operations.py")
|
||||
assert "is_classification_failed" in vm
|
||||
assert "REMOVE_FROM_LIST" in vm
|
||||
assert "REVIEW_MANUALLY" in vm
|
||||
assert "queue == \"marketing\"" in vm
|
||||
assert "sem oportunidade comercial" in vm
|
||||
|
||||
|
||||
def test_v483_cleanup_script_for_bad_opportunities_exists():
|
||||
script = read("scripts/cleanup_non_commercial_opportunities.py")
|
||||
assert "system_or_bounce_created_by_mistake" in script
|
||||
assert "--apply" in script
|
||||
assert "Dry-run" in script
|
||||
assert "mail delivery subsystem" in script
|
||||
44
tests/test_v484_fiscal_link_consistency.py
Normal file
44
tests/test_v484_fiscal_link_consistency.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v484_operations_uses_opportunity_fiscal_customer_before_task_customer_id():
|
||||
service = read("app/operations_service.py")
|
||||
assert "LEFT JOIN customers cu_opp ON cu_opp.id = o.local_customer_id" in service
|
||||
assert "LEFT JOIN customers cu_task ON cu_task.id::text = t.customer_id" in service
|
||||
assert "COALESCE(cu_opp.name, cu_task.name, '') AS fiscal_customer_name" in service
|
||||
assert "COALESCE(NULLIF(re.payload->'sender'->>'name',''), NULLIF(re.payload->'sender'->>'email',''), NULLIF(t.contact_id,''), '') AS customer_name" in service
|
||||
assert "cu.name AS fiscal_customer_name" not in service.split("UNION ALL", 1)[0]
|
||||
|
||||
|
||||
def test_v484_operations_blockers_use_actual_fiscal_missing_fields_not_only_association():
|
||||
guidance = read("app/admin_ui/guidance.py")
|
||||
assert "def work_item_fiscal_customer" in guidance
|
||||
assert "The work queue must not infer a fiscal customer from Chatwoot contact_id" in guidance
|
||||
assert "for missing in fiscal_customer_missing_fields(customer)" in guidance
|
||||
assert "Cliente fiscal sem {missing}" in guidance
|
||||
|
||||
|
||||
def test_v484_task_detail_does_not_say_no_critical_data_when_fiscal_readiness_has_warnings():
|
||||
service = read("app/task_service.py")
|
||||
tasks = read("app/admin_ui/pages/tasks.py")
|
||||
assert "cu.street_name AS linked_customer_street_name" in service
|
||||
assert "cu.postal_zone AS linked_customer_postal_zone" in service
|
||||
assert "cu.city_name AS linked_customer_city_name" in service
|
||||
assert "fiscal_missing_labels = fiscal_customer_missing_fields" in tasks
|
||||
assert "Cliente fiscal: {label}" in tasks
|
||||
assert "missing_items = list(prep_vm.get(\"missing_fields\") or [])" in tasks
|
||||
|
||||
|
||||
def test_v484_opportunity_queries_carry_full_fiscal_customer_fields():
|
||||
opp_service = read("app/opportunity_service.py")
|
||||
guidance = read("app/admin_ui/guidance.py")
|
||||
assert "c.street_name AS linked_customer_street_name" in opp_service
|
||||
assert "c.postal_zone AS linked_customer_postal_zone" in opp_service
|
||||
assert "c.city_name AS linked_customer_city_name" in opp_service
|
||||
assert "opportunity.get(\"linked_customer_street_name\")" in guidance
|
||||
50
tests/test_v485_bounce_ignore_card_ux.py
Normal file
50
tests/test_v485_bounce_ignore_card_ux.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v485_bounce_ndr_is_ignored_without_task_or_opportunity():
|
||||
catalog = read("app/action_catalog.py")
|
||||
decider = read("app/action_decider.py")
|
||||
analyzer = read("app/analyzer.py")
|
||||
task_service = read("app/task_service.py")
|
||||
opportunity = read("app/opportunity_service.py")
|
||||
|
||||
assert "IGNORE_BOUNCE" in catalog
|
||||
assert "Your message" in decider or "couldn" in decider
|
||||
assert "Recipient" in decider or "recipient" in decider
|
||||
assert "Office 365" in decider or "office 365" in decider
|
||||
assert 'action_result.action_code or "").upper() == "IGNORE_BOUNCE"' in analyzer
|
||||
assert 'return None' in task_service
|
||||
assert '"IGNORE_BOUNCE"' in opportunity
|
||||
assert "unknown to address" in opportunity
|
||||
|
||||
|
||||
def test_v485_operations_identity_is_operator_friendly_and_details_are_conditional():
|
||||
vm = read("app/admin_ui/view_models/operations.py")
|
||||
ops = read("app/admin_ui/pages/operations.py")
|
||||
|
||||
assert "operation_has_useful_details" in vm
|
||||
assert "Contacto sem identificação" in vm
|
||||
assert "_looks_like_generic_label" in vm
|
||||
assert "Contacto Chatwoot #" in vm # legacy comment only, not the returned title
|
||||
assert "operation_has_useful_details(item)" not in ops
|
||||
assert 'status_chip_html = "" if status_text == "normal"' in ops
|
||||
|
||||
|
||||
def test_v485_opportunity_cards_use_specific_cta_and_hide_low_value_fields():
|
||||
opportunities = read("app/admin_ui/pages/opportunities.py")
|
||||
|
||||
assert "_opportunity_card_identity" in opportunities
|
||||
assert "_opportunity_card_next_action" in opportunities
|
||||
assert "primary_action_label" in opportunities
|
||||
assert "Ver tarefa pendente" in opportunities
|
||||
assert "cf-opportunity-card-compact" in opportunities
|
||||
compact_card = opportunities.split('def _render_opportunity_card', 1)[1].split('def render_opportunities_board_partial', 1)[0]
|
||||
assert "money_html(opp.get('value_amount'))" not in compact_card
|
||||
assert "fmt_dt(opp.get('updated_at'))" not in compact_card
|
||||
assert "pending_chip" not in compact_card
|
||||
36
tests/test_v486_opportunity_board_layout_cleanup.py
Normal file
36
tests/test_v486_opportunity_board_layout_cleanup.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v486_opportunity_board_uses_clean_non_overlapping_layout():
|
||||
opportunities = read("app/admin_ui/pages/opportunities.py")
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
|
||||
assert "cf-opportunities-board-scroll" in opportunities
|
||||
assert "cf-opportunities-board-grid" in opportunities
|
||||
assert "cf-opportunity-stage" in opportunities
|
||||
assert "cf-opportunity-card-clean" in opportunities
|
||||
assert "cf-opportunity-next-action" in opportunities
|
||||
|
||||
# Cards no longer repeat stage/priority chips inside each opportunity card.
|
||||
card_fn = opportunities.split("def _render_opportunity_card", 1)[1].split("def render_opportunities_board_partial", 1)[0]
|
||||
assert "opportunity_stage_badge" not in card_fn
|
||||
assert "opportunity_priority_chip" not in card_fn
|
||||
assert "d-flex justify-content-between" not in card_fn
|
||||
|
||||
# Columns have a minimum width instead of being squeezed until badges overlap.
|
||||
assert "grid-template-columns: repeat(4, minmax(300px, 1fr))" in styles
|
||||
assert "overflow-x: auto" in styles
|
||||
assert "min-width: 1220px" in styles
|
||||
|
||||
|
||||
def test_v486_opportunity_copy_describes_relevant_fields_only():
|
||||
opportunities = read("app/admin_ui/pages/opportunities.py")
|
||||
|
||||
assert "identificação clara, assunto, próxima ação e bloqueios relevantes" in opportunities
|
||||
assert "valor e pendências" not in opportunities
|
||||
52
tests/test_v488_operations_classic_cards.py
Normal file
52
tests/test_v488_operations_classic_cards.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v488_operations_returns_to_classic_card_list_layout():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
|
||||
assert 'id="operations-work-items" class="cf-live-panel"' in operations
|
||||
assert 'cf-work-card' in operations
|
||||
assert 'cf-work-section-title' in operations
|
||||
assert 'cf-work-section' in operations
|
||||
assert 'cf-workbench-layout' not in operations
|
||||
assert 'operations-detail-panel' not in operations
|
||||
assert '@router.get("/operations/partials/item-detail"' not in operations
|
||||
|
||||
assert 'grid-template-columns:minmax(360px,440px) minmax(0,1fr)' not in styles
|
||||
assert '.cf-work-detail-panel{position:sticky' not in styles
|
||||
assert '.cf-work-row{' not in styles
|
||||
|
||||
|
||||
def test_v488_operations_keeps_simplified_cards_without_inline_details():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
|
||||
assert 'operation_card_title(item)' in operations
|
||||
assert 'operation_card_subtitle(item)' in operations
|
||||
assert 'operation_has_useful_details(item)' not in operations
|
||||
assert 'Cliente fiscal' not in operations.split('def _render_work_item', 1)[1].split('details_html = ""', 1)[0]
|
||||
assert '<summary>Ver detalhes</summary>' not in operations
|
||||
assert 'operation_origin_label(item)' not in operations
|
||||
|
||||
|
||||
def test_v488_operations_keeps_htmx_filter_partial_only():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
|
||||
assert '@router.get("/operations/partials/work-items"' in operations
|
||||
assert 'hx-target="#operations-work-items"' in operations
|
||||
assert 'hx-swap="outerHTML"' in operations
|
||||
assert 'hx-push-url' in operations
|
||||
|
||||
|
||||
def test_v488_documentation_states_classic_cards_replaced_workbench():
|
||||
doc = read("docs/CLIENTFLOW_V488_OPERATIONS_CLASSIC_CARDS.md")
|
||||
assert "Operations Classic Cards" in doc
|
||||
assert "sem painel lateral fixo" in doc
|
||||
assert "Não requer migração de base de dados" in doc
|
||||
assert "Não altera regras de negócio" in doc
|
||||
36
tests/test_v489_operations_card_polish.py
Normal file
36
tests/test_v489_operations_card_polish.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v489_operations_cards_remove_details_completely():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
|
||||
assert "Ver detalhes" not in operations
|
||||
assert "cf-work-details" not in operations
|
||||
assert "operation_has_useful_details(item)" not in operations
|
||||
assert "operation_origin_label(item)" not in operations
|
||||
assert "cf-work-details" not in styles
|
||||
|
||||
|
||||
def test_v489_operations_cards_have_primary_and_secondary_action_hierarchy():
|
||||
operations = read("app/admin_ui/pages/operations.py")
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
|
||||
assert "cf-work-primary-action" in operations
|
||||
assert "cf-work-secondary-actions" in operations
|
||||
assert ".cf-work-primary-action" in styles
|
||||
assert ".cf-work-secondary-actions" in styles
|
||||
assert "border-top:1px solid #eef2f7" in styles
|
||||
|
||||
|
||||
def test_v489_documentation_exists():
|
||||
doc = read("docs/CLIENTFLOW_V489_OPERATIONS_CARD_POLISH.md")
|
||||
assert "Operations Card Polish" in doc
|
||||
assert "Ver detalhes" in doc
|
||||
assert "Não requer migração de base de dados" in doc
|
||||
50
tests/test_v490_operations_noise_cleanup.py
Normal file
50
tests/test_v490_operations_noise_cleanup.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v490_has_shared_noise_detector_and_filters_operations_queue():
|
||||
noise = read("app/operation_noise.py")
|
||||
ops = read("app/operations_service.py")
|
||||
|
||||
assert "SYSTEM_SENDER_PATTERNS" in noise
|
||||
assert "BOUNCE_NDR_PATTERNS" in noise
|
||||
assert "Mail Delivery System".casefold() in noise.casefold()
|
||||
assert "undeliverable" in noise
|
||||
assert "recipient" in noise
|
||||
assert "is_noise_operation_item" in ops
|
||||
assert "is_low_value_no_opportunity_item" in ops
|
||||
assert "not in the daily work queue" in ops
|
||||
|
||||
|
||||
def test_v490_generic_review_and_marketing_are_not_high_priority_by_default():
|
||||
task_service = read("app/task_service.py")
|
||||
vm = read("app/admin_ui/view_models/operations.py")
|
||||
|
||||
assert 'code in {"REVIEW_MANUALLY", "REMOVE_FROM_LIST", "MARK_NO_INTEREST", "IGNORE_SPAM", "NO_ACTION", "IGNORE_BOUNCE"}' in task_service
|
||||
assert 'return "baixa"' in task_service
|
||||
assert 'action_code in {"REMOVE_FROM_LIST", "IGNORE_SPAM", "NO_ACTION", "REVIEW_MANUALLY"}' in vm
|
||||
assert "return False" in vm.split('def is_high_priority', 1)[1].split('def is_blocked', 1)[0]
|
||||
|
||||
|
||||
def test_v490_cleanup_script_marks_noise_tasks_skipped_dry_run_by_default():
|
||||
script = read("scripts/cleanup_operations_noise.py")
|
||||
|
||||
assert "Dry-run only" in script
|
||||
assert "--apply" in script
|
||||
assert "task_skipped_noise_cleanup" in script
|
||||
assert "cleanup_operations_noise" in script
|
||||
assert "Marked" in script
|
||||
assert "Mail Delivery System".casefold() in script.casefold()
|
||||
|
||||
|
||||
def test_v490_documentation_exists():
|
||||
doc = read("docs/CLIENTFLOW_V490_OPERATIONS_NOISE_CLEANUP.md")
|
||||
assert "Operations Noise Cleanup" in doc
|
||||
assert "postmaster" in doc
|
||||
assert "Mail Delivery" in doc
|
||||
assert "Não requer migração de base de dados" in doc
|
||||
89
tests/test_v4911_odoo_name_match_reconciliation.py
Normal file
89
tests/test_v4911_odoo_name_match_reconciliation.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""v4.9.11 regression tests for Odoo name-only reconciliation.
|
||||
|
||||
Odoo customers may not have VAT/NIF filled in. Reconciliation should still be
|
||||
able to suggest/link by normalized company name and should not abort the whole
|
||||
process candidate list when an individual suggestion lookup fails.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
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_company_name_normalization_matches_odoo_without_nif():
|
||||
score = svc._name_match_score(
|
||||
"ELEGANTLEGACY, LDA VALONGO Portugal",
|
||||
"Elegantlegacy Lda",
|
||||
"Oportunidade · elegantlegacy",
|
||||
)
|
||||
assert score >= 35
|
||||
|
||||
|
||||
def test_single_odoo_sale_without_nif_becomes_process_candidate(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"list_reconciliation_items",
|
||||
lambda *, status, limit, days: [
|
||||
{
|
||||
"id": "odoo-sale-274",
|
||||
"source_system": "odoo",
|
||||
"external_type": "odoo_sale_order",
|
||||
"title": "Venda Odoo sem oportunidade · S00274",
|
||||
"customer_name": "ELEGANTLEGACY, LDA",
|
||||
"customer_tax_id": "",
|
||||
"customer_email": "",
|
||||
"document_number": "S00274",
|
||||
"document_date": "2026-06-02",
|
||||
"amount": "537.00",
|
||||
"currency": "EUR",
|
||||
"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"] == "name"
|
||||
assert candidate["confidence"] == "alta"
|
||||
assert candidate["identity_reason"] == "nome fiscal"
|
||||
assert candidate["customer_name"] == "ELEGANTLEGACY, LDA"
|
||||
assert candidate["suggested_stage"] == "ODOO_ORDER_CREATED"
|
||||
assert candidate["suggested_action"] == "SEND_INVOICE"
|
||||
assert candidate["item_ids"] == ["odoo-sale-274"]
|
||||
|
||||
|
||||
def test_list_process_candidates_is_resilient_when_item_query_fails(monkeypatch):
|
||||
def fail_listing(*, status, limit, days):
|
||||
raise RuntimeError("simulated db lookup failure")
|
||||
|
||||
monkeypatch.setattr(svc, "list_reconciliation_items", fail_listing)
|
||||
|
||||
assert svc.list_reconciliation_process_candidates(status="open", days=7, limit=10) == []
|
||||
|
||||
|
||||
def test_person_name_only_is_not_high_confidence_identity():
|
||||
confidence, reason = svc._process_group_identity_label(
|
||||
"name",
|
||||
[{"customer_name": "Bruno Oliveira", "customer_tax_id": "", "source_system": "odoo"}],
|
||||
)
|
||||
|
||||
assert confidence == "média"
|
||||
assert reason == "nome"
|
||||
129
tests/test_v4912_odoo_fulfilment_reconciliation.py
Normal file
129
tests/test_v4912_odoo_fulfilment_reconciliation.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""v4.9.12 tests for fiscal-customer Odoo fulfilment reconstruction.
|
||||
|
||||
The Odoo connector may only provide a reliable company name. Once a sale order
|
||||
is staged, ClientFlow should still import order fulfilment evidence and rebuild
|
||||
a useful process timeline: sale -> delivery done -> invoice to issue.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
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.config" not in sys.modules:
|
||||
app_config = types.ModuleType("app.config")
|
||||
app_config.settings = types.SimpleNamespace(odoo_enabled=True)
|
||||
sys.modules["app.config"] = app_config
|
||||
|
||||
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 external_reconciliation_sync as sync
|
||||
from app import reconciliation_service as svc
|
||||
|
||||
|
||||
class FakeOdooClient:
|
||||
def search_read(self, model, domain=None, fields=None, *, limit=500, offset=0, order="id asc", context=None):
|
||||
if model == "sale.order":
|
||||
return [{
|
||||
"id": 274,
|
||||
"name": "S00274",
|
||||
"state": "sale",
|
||||
"date_order": "2026-06-02 11:25:49",
|
||||
"partner_id": [288, "ELEGANTLEGACY, LDA"],
|
||||
"amount_total": 537.0,
|
||||
"currency_id": [125, "EUR"],
|
||||
"invoice_status": "to invoice",
|
||||
"order_line": [1001, 1002],
|
||||
}]
|
||||
if model == "res.partner":
|
||||
return [{"id": 288, "name": "ELEGANTLEGACY, LDA", "vat": "", "email": ""}]
|
||||
if model == "sale.order.line":
|
||||
return [
|
||||
{"id": 1001, "order_id": [274, "S00274"], "product_id": [11, "Wallbox 7.4KW"], "name": "Wallbox 7.4KW", "product_uom_qty": 1.0, "qty_delivered": 1.0, "qty_invoiced": 0.0, "price_unit": 179.0, "price_total": 179.0},
|
||||
{"id": 1002, "order_id": [274, "S00274"], "product_id": [12, "Wallbox 11KW"], "name": "Wallbox 11KW", "product_uom_qty": 1.0, "qty_delivered": 1.0, "qty_invoiced": 0.0, "price_unit": 179.0, "price_total": 179.0},
|
||||
]
|
||||
if model == "stock.picking":
|
||||
return [{"id": 295, "name": "WH/OUT/00295", "state": "done", "origin": "S00274", "picking_type_id": [1, "Delivery Orders"], "scheduled_date": "2026-06-02 12:25:00", "date_done": "2026-06-02 13:12:00"}]
|
||||
if model == "mrp.production":
|
||||
return []
|
||||
return []
|
||||
|
||||
|
||||
def test_odoo_sync_enriches_sale_with_lines_pickings_and_fulfilment(monkeypatch):
|
||||
monkeypatch.setattr(sync.settings, "odoo_enabled", True, raising=False)
|
||||
|
||||
odoo_module = types.ModuleType("app.odoo_client")
|
||||
odoo_module.OdooClient = lambda: FakeOdooClient()
|
||||
monkeypatch.setitem(sys.modules, "app.odoo_client", odoo_module)
|
||||
|
||||
staged = []
|
||||
monkeypatch.setattr(sync, "upsert_reconciliation_item", lambda **kw: staged.append(kw) or kw)
|
||||
|
||||
result = sync.sync_odoo_reconciliation_candidates(days=7, limit=10)
|
||||
|
||||
assert result["seen"] == 1
|
||||
candidate = staged[0]
|
||||
record = candidate["payload"]["record"]
|
||||
assert record["partner_external_id"] == 288
|
||||
assert record["order_lines"][0]["product_name"] == "Wallbox 7.4KW"
|
||||
assert record["pickings"][0]["name"] == "WH/OUT/00295"
|
||||
assert record["fulfilment"]["delivery_done"] is True
|
||||
assert record["fulfilment"]["invoice_pending"] is True
|
||||
assert candidate["suggested_action"] == "SEND_INVOICE"
|
||||
|
||||
|
||||
def test_process_timeline_includes_delivery_done_and_invoice_pending():
|
||||
item = {
|
||||
"id": "odoo-sale-274",
|
||||
"source_system": "odoo",
|
||||
"external_type": "odoo_sale_order",
|
||||
"document_number": "S00274",
|
||||
"document_date": "2026-06-02",
|
||||
"amount": "537.00",
|
||||
"currency": "EUR",
|
||||
"payload": {
|
||||
"record": {
|
||||
"name": "S00274",
|
||||
"invoice_status": "to invoice",
|
||||
"order_lines": [{"product_name": "Wallbox 7.4KW", "qty_delivered": 1.0, "qty_invoiced": 0.0}],
|
||||
"fulfilment": {
|
||||
"delivery_done": True,
|
||||
"invoice_pending": True,
|
||||
"physical_status": "shipped",
|
||||
"outgoing_pickings": [{"name": "WH/OUT/00295", "state": "done", "date_done": "2026-06-02 13:12:00"}],
|
||||
"lines": [{"product_name": "Wallbox 7.4KW", "qty_delivered": 1.0, "qty_invoiced": 0.0}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
state = svc.infer_reconciliation_process_state([item])
|
||||
labels = [step["label"] for step in state["steps"]]
|
||||
types = [step["external_type"] for step in state["steps"]]
|
||||
|
||||
assert "Venda/encomenda encontrada no Odoo" in labels
|
||||
assert "Entrega Odoo concluída" in labels
|
||||
assert "Fatura por emitir" in labels
|
||||
assert "odoo_delivery" in types
|
||||
assert "odoo_invoice_pending" in types
|
||||
assert state["action_code"] == "SEND_INVOICE"
|
||||
assert state["stage"] == "SHIPMENT_CREATED"
|
||||
|
||||
|
||||
def test_static_hooks_for_partner_mapping_and_window_cleanup_reopen():
|
||||
service_source = open("app/reconciliation_service.py", encoding="utf-8").read()
|
||||
assert "external_customer_mappings" in service_source
|
||||
assert "mapeamento Odoo confirmado" in service_source
|
||||
assert "payload - 'window_cleanup'" in service_source
|
||||
assert "_upsert_odoo_operation_links_from_item" in service_source
|
||||
109
tests/test_v4913_apply_reconstructed_process.py
Normal file
109
tests/test_v4913_apply_reconstructed_process.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""v4.9.13 regression tests for applying reconstructed processes.
|
||||
|
||||
The reconstruction is useful only if confirming a process updates the
|
||||
opportunity pipeline, Odoo links and commercial lines instead of leaving a loose
|
||||
reconciliation reference.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
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": "financeiro" if code == "SEND_INVOICE" else "rever",
|
||||
"action": "Enviar fatura ao cliente" if code == "SEND_INVOICE" else code,
|
||||
"action_required": True,
|
||||
"safe_to_post": False,
|
||||
}
|
||||
sys.modules["app.action_catalog"] = action_catalog
|
||||
|
||||
from app import reconciliation_service as svc
|
||||
|
||||
|
||||
def _odoo_item_with_delivery():
|
||||
return {
|
||||
"id": "odoo-sale-274",
|
||||
"source_system": "odoo",
|
||||
"external_type": "odoo_sale_order",
|
||||
"external_id": "274",
|
||||
"document_number": "S00274",
|
||||
"document_date": "2026-06-02",
|
||||
"amount": "537.00",
|
||||
"currency": "EUR",
|
||||
"payload": {
|
||||
"record": {
|
||||
"id": 274,
|
||||
"name": "S00274",
|
||||
"partner_external_id": 288,
|
||||
"partner_name": "ELEGANTLEGACY, LDA",
|
||||
"invoice_status": "to invoice",
|
||||
"fulfilment": {
|
||||
"physical_status": "shipped",
|
||||
"delivery_done": True,
|
||||
"invoice_pending": True,
|
||||
"lines": [
|
||||
{"id": 643, "product_name": "Wallbox 7.4KW", "qty_ordered": 1.0, "qty_delivered": 1.0, "qty_invoiced": 0.0, "price_unit": 160.0, "price_total": 160.0},
|
||||
{"id": 644, "product_name": "Wallbox 11KW", "qty_ordered": 1.0, "qty_delivered": 1.0, "qty_invoiced": 0.0, "price_unit": 178.0, "price_total": 178.0},
|
||||
{"id": 645, "product_name": "Wallbox 22KW", "qty_ordered": 1.0, "qty_delivered": 1.0, "qty_invoiced": 0.0, "price_unit": 199.0, "price_total": 199.0},
|
||||
{"id": 646, "product_name": "[Delivery_007] Standard delivery", "qty_ordered": 1.0, "qty_delivered": 0.0, "qty_invoiced": 0.0, "price_unit": 0.0, "price_total": 0.0},
|
||||
],
|
||||
"outgoing_pickings": [{"name": "WH/OUT/00295", "state": "done", "date_done": "2026-06-02 12:12:10"}],
|
||||
"productions": [{"name": "WH/MO/00477", "state": "done", "product_name": "Wallbox 7.4KW"}],
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_odoo_importable_lines_skip_zero_delivery_carrier_line():
|
||||
lines = svc._odoo_importable_lines(_odoo_item_with_delivery())
|
||||
|
||||
assert [line["product_name"] for line in lines] == [
|
||||
"Wallbox 7.4KW",
|
||||
"Wallbox 11KW",
|
||||
"Wallbox 22KW",
|
||||
]
|
||||
assert sum(float(line["price_total"]) for line in lines) == 537.0
|
||||
|
||||
|
||||
def test_process_state_for_delivered_odoo_sale_points_to_invoice():
|
||||
state = svc.infer_reconciliation_process_state([_odoo_item_with_delivery()])
|
||||
|
||||
assert state["stage"] == "SHIPMENT_CREATED"
|
||||
assert state["action_code"] == "SEND_INVOICE"
|
||||
assert [step["external_type"] for step in state["steps"]] == [
|
||||
"odoo_sale_order",
|
||||
"odoo_sale_lines",
|
||||
"odoo_delivery",
|
||||
"odoo_invoice_pending",
|
||||
]
|
||||
|
||||
|
||||
def test_apply_reconstructed_process_hooks_are_present():
|
||||
source = open("app/reconciliation_service.py", encoding="utf-8").read()
|
||||
|
||||
assert "def _apply_reconstructed_process_to_opportunity" in source
|
||||
assert "_apply_reconstructed_process_to_opportunity(conn, items, opportunity_id" in source
|
||||
assert "def _upsert_opportunity_items_from_odoo_item" in source
|
||||
assert "metadata->>'source_line_id'" in source
|
||||
assert "system, external_type)" in source and "physical_validation" in source
|
||||
assert "Venda Odoo" not in source or "odoo_invoice_pending" in source
|
||||
|
||||
|
||||
def test_reconciliation_ui_supports_active_days_window():
|
||||
ui = open("app/admin_ui/pages/reconciliation.py", encoding="utf-8").read()
|
||||
|
||||
assert "days: Optional[int] = 3" in ui
|
||||
assert "Hoje" in ui and "7 dias" in ui and "30 dias" in ui
|
||||
assert "sync_odoo_reconciliation_candidates(limit=50, days=days)" in ui
|
||||
19
tests/test_v4914_reconciliation_link_hotfix.py
Normal file
19
tests/test_v4914_reconciliation_link_hotfix.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_v4914_schema_guards_for_process_link_columns():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
for fragment in [
|
||||
"ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS last_synced_at",
|
||||
"ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS action_code",
|
||||
"ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS to_stage",
|
||||
"ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS total_price",
|
||||
"ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS metadata",
|
||||
]:
|
||||
assert fragment in source
|
||||
|
||||
|
||||
def test_v4914_reconciliation_link_route_is_guarded():
|
||||
source = Path("app/admin_ui/pages/reconciliation.py").read_text()
|
||||
assert 'logger.exception("failed to link reconstructed reconciliation process' in source
|
||||
assert 'Erro%20ao%20ligar%20processo' in source
|
||||
8
tests/test_v4915_reconciliation_amount_cast.py
Normal file
8
tests/test_v4915_reconciliation_amount_cast.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_v4915_reconstruction_amount_update_casts_numeric_parameter():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
assert "WHEN CAST(:amount AS NUMERIC) IS NOT NULL" in source
|
||||
assert "THEN CAST(:amount AS NUMERIC)" in source
|
||||
assert "WHEN :amount IS NOT NULL" not in source
|
||||
75
tests/test_v491_operational_reconciliation.py
Normal file
75
tests/test_v491_operational_reconciliation.py
Normal file
@@ -0,0 +1,75 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v491_reconciliation_service_adds_staging_schema_and_payment_proofs():
|
||||
service = read("app/reconciliation_service.py")
|
||||
db = read("app/db.py")
|
||||
|
||||
assert "CREATE TABLE IF NOT EXISTS reconciliation_items" in service
|
||||
assert "CREATE TABLE IF NOT EXISTS payment_proofs" in service
|
||||
assert "source_system TEXT NOT NULL" in service
|
||||
assert "external_type TEXT NOT NULL" in service
|
||||
assert "ux_reconciliation_items_source_external" in service
|
||||
assert "ensure_reconciliation_schema" in db
|
||||
|
||||
|
||||
def test_v491_reconciliation_page_and_navigation_exist():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
router = read("app/admin_ui/router.py")
|
||||
nav = read("app/admin_ui/navigation.py")
|
||||
|
||||
assert '@router.get("/reconciliation"' in page
|
||||
assert '@router.get("/reconciliacao"' in page
|
||||
assert "Registar pedido externo" in page
|
||||
assert "Adicionar comprovativo" in page
|
||||
assert "Criar oportunidade" in page
|
||||
assert "Reconciliação" in nav
|
||||
assert "router.include_router(reconciliation.router)" in router
|
||||
|
||||
|
||||
def test_v491_external_intake_creates_opportunity_without_fake_chatwoot_ids():
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "def create_external_request" in service
|
||||
assert "manual_external_intake" in service
|
||||
assert "source_channel" in service
|
||||
assert "WhatsApp" in read("app/admin_ui/pages/reconciliation.py")
|
||||
assert "conversation_id" not in service.split("def create_external_request", 1)[1].split("def create_payment_proof", 1)[0]
|
||||
|
||||
|
||||
def test_v491_payment_proof_never_confirms_payment_automatically():
|
||||
service = read("app/reconciliation_service.py")
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
doc = read("docs/CLIENTFLOW_V491_OPERATIONAL_RECONCILIATION.md")
|
||||
|
||||
assert "pending_validation" in service
|
||||
assert "Comprovativo recebido não confirma pagamento" in page
|
||||
assert "CONFIRM_PAYMENT" in service
|
||||
assert "não confirma pagamento" in doc
|
||||
assert "payment_confirmed" not in service
|
||||
|
||||
|
||||
def test_v491_sync_script_creates_reconciliation_candidates_idempotently():
|
||||
script = read("scripts/sync_reconciliation_candidates.py")
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "sync_local_documents_without_opportunity" in script
|
||||
assert "commercial_documents" in service
|
||||
assert "opportunity_id IS NULL" in service
|
||||
assert "reconcile:commercial_document" in service
|
||||
assert "created_or_updated" in script
|
||||
|
||||
|
||||
def test_v491_documentation_describes_operator_confirmation_model():
|
||||
doc = read("docs/CLIENTFLOW_V491_OPERATIONAL_RECONCILIATION.md")
|
||||
|
||||
assert "Operational Reconciliation" in doc
|
||||
assert "Operador confirma" in doc
|
||||
assert "Não confirma pagamentos automaticamente" in doc
|
||||
assert "Não usa LLM" in doc
|
||||
104
tests/test_v4920_reconciliation_multiple_orders.py
Normal file
104
tests/test_v4920_reconciliation_multiple_orders.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""v4.9.20: one fiscal customer can have multiple simultaneous orders.
|
||||
|
||||
Customer identity (NIF/fiscal name) must not be confused with commercial
|
||||
operation identity. Two Odoo sale orders for the same company should be shown
|
||||
as two process candidates, not collapsed into one card.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
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 _odoo_sale(id_, number, amount, product):
|
||||
return {
|
||||
"id": id_,
|
||||
"source_system": "odoo",
|
||||
"external_type": "odoo_sale_order",
|
||||
"title": f"Venda Odoo sem oportunidade · {number}",
|
||||
"customer_name": "ACZCO BRAGA ENERGY, LDA",
|
||||
"customer_tax_id": "517249200",
|
||||
"document_number": number,
|
||||
"document_date": "2026-06-05",
|
||||
"amount": amount,
|
||||
"currency": "EUR",
|
||||
"payload": {
|
||||
"record": {
|
||||
"name": number,
|
||||
"invoice_status": "to invoice",
|
||||
"fulfilment": {
|
||||
"invoice_pending": True,
|
||||
"lines": [{"id": f"line-{number}", "product_name": product, "qty_ordered": 1, "price_total": amount}],
|
||||
},
|
||||
}
|
||||
},
|
||||
"operation_suggestions": [],
|
||||
}
|
||||
|
||||
|
||||
def test_same_company_two_odoo_orders_become_two_process_candidates(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"list_reconciliation_items",
|
||||
lambda *, status, limit, days: [
|
||||
_odoo_sale("sale-279", "S00279", "638.60", "Wallbox 7.4KW"),
|
||||
_odoo_sale("sale-280", "S00280", "190.00", "Cabo"),
|
||||
],
|
||||
)
|
||||
|
||||
candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10)
|
||||
|
||||
assert len(candidates) == 2
|
||||
process_keys = {candidate["process_key"] for candidate in candidates}
|
||||
assert any("S00279" in key for key in process_keys)
|
||||
assert any("S00280" in key for key in process_keys)
|
||||
assert {tuple(candidate["item_ids"]) for candidate in candidates} == {("sale-279",), ("sale-280",)}
|
||||
assert all(candidate["match_key"] == "nif" for candidate in candidates)
|
||||
assert all(candidate["suggested_action"] == "SEND_INVOICE" for candidate in candidates)
|
||||
|
||||
|
||||
def test_loose_quote_assigns_to_matching_order_by_amount(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"list_reconciliation_items",
|
||||
lambda *, status, limit, days: [
|
||||
{
|
||||
"id": "quote-279",
|
||||
"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-05",
|
||||
"amount": "638.60",
|
||||
"currency": "EUR",
|
||||
"operation_suggestions": [],
|
||||
},
|
||||
_odoo_sale("sale-279", "S00279", "638.60", "Wallbox 7.4KW"),
|
||||
_odoo_sale("sale-280", "S00280", "190.00", "Cabo"),
|
||||
],
|
||||
)
|
||||
|
||||
candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10)
|
||||
|
||||
assert len(candidates) == 2
|
||||
grouped = {candidate["operation_key"]: set(candidate["item_ids"]) for candidate in candidates}
|
||||
assert grouped["odoo_sale_order:S00279"] == {"quote-279", "sale-279"}
|
||||
assert grouped["odoo_sale_order:S00280"] == {"sale-280"}
|
||||
112
tests/test_v4920_reconciliation_multiple_purchases.py
Normal file
112
tests/test_v4920_reconciliation_multiple_purchases.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""v4.9.20: fiscal customer identity is not purchase identity."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
|
||||
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 _jasmin_doc(id_, external_type, number, date, amount):
|
||||
return {
|
||||
"id": id_,
|
||||
"source_system": "jasmin",
|
||||
"external_type": external_type,
|
||||
"title": f"Documento Jasmin · {number}",
|
||||
"customer_name": "ACZCO BRAGA ENERGY, LDA",
|
||||
"customer_tax_id": "517249200",
|
||||
"document_number": number,
|
||||
"document_date": date,
|
||||
"amount": amount,
|
||||
"currency": "EUR",
|
||||
"operation_suggestions": [],
|
||||
}
|
||||
|
||||
|
||||
def test_same_company_two_jasmin_quotes_stay_as_two_purchase_candidates(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"list_reconciliation_items",
|
||||
lambda *, status, limit, days: [
|
||||
_jasmin_doc("quote-154", "jasmin_quotation", "ORC.ORC2026.154", "2026-06-03", "638.60"),
|
||||
_jasmin_doc("quote-160", "jasmin_quotation", "ORC.ORC2026.160", "2026-06-06", "190.00"),
|
||||
],
|
||||
)
|
||||
|
||||
candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10)
|
||||
|
||||
assert len(candidates) == 2
|
||||
keys = {candidate["operation_key"] for candidate in candidates}
|
||||
assert "jasmin_quotation:ORC.ORC2026.154" in keys
|
||||
assert "jasmin_quotation:ORC.ORC2026.160" in keys
|
||||
assert {tuple(candidate["item_ids"]) for candidate in candidates} == {("quote-154",), ("quote-160",)}
|
||||
|
||||
|
||||
def test_quote_and_invoice_with_same_amount_and_near_date_can_form_one_purchase(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"list_reconciliation_items",
|
||||
lambda *, status, limit, days: [
|
||||
_jasmin_doc("quote-154", "jasmin_quotation", "ORC.ORC2026.154", "2026-06-03", "638.60"),
|
||||
_jasmin_doc("invoice-80", "jasmin_invoice", "FA2026.80", "2026-06-05", "638.60"),
|
||||
_jasmin_doc("quote-160", "jasmin_quotation", "ORC.ORC2026.160", "2026-06-20", "190.00"),
|
||||
],
|
||||
)
|
||||
|
||||
candidates = svc.list_reconciliation_process_candidates(status="open", days=30, limit=10)
|
||||
|
||||
grouped = {candidate["operation_key"]: set(candidate["item_ids"]) for candidate in candidates}
|
||||
assert grouped["jasmin_quotation:ORC.ORC2026.154"] == {"quote-154", "invoice-80"}
|
||||
assert grouped["jasmin_quotation:ORC.ORC2026.160"] == {"quote-160"}
|
||||
|
||||
|
||||
def test_payment_proof_assigns_only_when_order_match_is_unambiguous(monkeypatch):
|
||||
order_a = {
|
||||
"id": "sale-279",
|
||||
"source_system": "odoo",
|
||||
"external_type": "odoo_sale_order",
|
||||
"title": "Venda Odoo sem oportunidade · S00279",
|
||||
"customer_name": "ACZCO BRAGA ENERGY, LDA",
|
||||
"customer_tax_id": "517249200",
|
||||
"document_number": "S00279",
|
||||
"document_date": "2026-06-05",
|
||||
"amount": "638.60",
|
||||
"currency": "EUR",
|
||||
"payload": {"record": {"name": "S00279", "invoice_status": "to invoice"}},
|
||||
"operation_suggestions": [],
|
||||
}
|
||||
order_b = {**order_a, "id": "sale-280", "document_number": "S00280", "amount": "190.00", "payload": {"record": {"name": "S00280", "invoice_status": "to invoice"}}}
|
||||
proof = {
|
||||
"id": "proof-279",
|
||||
"source_system": "email",
|
||||
"external_type": "payment_proof",
|
||||
"title": "Comprovativo recebido",
|
||||
"customer_name": "ACZCO BRAGA ENERGY, LDA",
|
||||
"customer_tax_id": "517249200",
|
||||
"document_number": "comprovativo-279",
|
||||
"document_date": "2026-06-06",
|
||||
"amount": "638.60",
|
||||
"currency": "EUR",
|
||||
"operation_suggestions": [],
|
||||
}
|
||||
monkeypatch.setattr(svc, "list_reconciliation_items", lambda *, status, limit, days: [order_a, order_b, proof])
|
||||
|
||||
candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10)
|
||||
|
||||
grouped = {candidate["operation_key"]: set(candidate["item_ids"]) for candidate in candidates}
|
||||
assert grouped["odoo_sale_order:S00279"] == {"sale-279", "proof-279"}
|
||||
assert grouped["odoo_sale_order:S00280"] == {"sale-280"}
|
||||
29
tests/test_v4922_reconciliation_rebuild_button.py
Normal file
29
tests/test_v4922_reconciliation_rebuild_button.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_reconciliation_page_has_safe_rebuild_button():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert '/reconciliation/rebuild' in page
|
||||
assert 'Apagar e correr novamente' in page
|
||||
assert 'Não apaga clientes, oportunidades nem documentos reais' in page
|
||||
assert 'reset_generated_reconciliation_items' in service
|
||||
assert 'opportunity_id IS NULL' in service
|
||||
assert 'CREATE TABLE {backup_table} AS SELECT * FROM reconciliation_items' in service
|
||||
|
||||
|
||||
def test_rebuild_endpoint_resets_then_syncs_external_candidates():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
|
||||
rebuild_block = page.split('@router.post("/reconciliation/rebuild")', 1)[1].split('@router.post("/reconciliation/cleanup-window")', 1)[0]
|
||||
assert 'reset_generated_reconciliation_items' in rebuild_block
|
||||
assert 'apply=True' in rebuild_block
|
||||
assert 'sync_all_external_reconciliation_candidates' in rebuild_block
|
||||
assert 'limit=200' in rebuild_block
|
||||
49
tests/test_v4923_reconciliation_customer_first_pipeline.py
Normal file
49
tests/test_v4923_reconciliation_customer_first_pipeline.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v4923_reconciliation_sync_runs_customer_seed_before_documents():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
block = sync.split("async def sync_all_external_reconciliation_candidates", 1)[1]
|
||||
|
||||
assert "sync_external_fiscal_customers_for_reconciliation" in sync
|
||||
assert "sync_jasmin_fiscal_customers_for_reconciliation" in sync
|
||||
assert "sync_odoo_fiscal_customers_for_reconciliation" in sync
|
||||
assert block.index("customer_result = await sync_external_fiscal_customers_for_reconciliation") < block.index("jasmin_result = await sync_jasmin_reconciliation_candidates")
|
||||
assert block.index("jasmin_result = await sync_jasmin_reconciliation_candidates") < block.index("odoo_result = await asyncio.to_thread(sync_odoo_reconciliation_candidates")
|
||||
assert "customer_seen" in block
|
||||
assert "customers_created_or_updated" in block
|
||||
|
||||
|
||||
def test_v4923_candidates_are_linked_to_seeded_fiscal_customer():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
|
||||
assert "_upsert_fiscal_customer_from_external_record(record, source_system=\"jasmin\")" in sync
|
||||
assert "_upsert_fiscal_customer_from_external_record(record, source_system=\"odoo\")" in sync
|
||||
assert "\"customer_id\": customer_id" in sync
|
||||
assert "source_system == \"odoo\" and not (tax_id or is_company or _looks_like_company_name(name))" in sync
|
||||
|
||||
|
||||
def test_v4923_customer_upsert_uses_fiscal_name_when_nif_missing():
|
||||
service = read("app/commercial_service.py")
|
||||
|
||||
assert "def normalize_fiscal_name" in service
|
||||
assert "No domínio ClientFlow o nome fiscal de empresa é tratado como chave" in service
|
||||
assert "normalized_name = normalize_fiscal_name" in service
|
||||
assert "regexp_replace(lower(name)" in service
|
||||
assert "metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB)" in service
|
||||
|
||||
|
||||
def test_v4923_ui_explains_sequential_rebuild_pipeline():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
script = read("scripts/sync_external_reconciliation.py")
|
||||
|
||||
assert "clientes fiscais → documentos Jasmin → vendas Odoo" in page
|
||||
assert "clientes fiscais analisados" in page
|
||||
assert "clientes criados/atualizados" in page
|
||||
assert "--customers" in script
|
||||
96
tests/test_v4924_reconciliation_process_review.py
Normal file
96
tests/test_v4924_reconciliation_process_review.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""v4.9.24: reconciliation process review must be explainable and actionable."""
|
||||
|
||||
import sys
|
||||
import types
|
||||
from pathlib import Path
|
||||
|
||||
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
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _item(id_, external_type, number, date, amount):
|
||||
return {
|
||||
"id": id_,
|
||||
"source_system": "jasmin" if external_type.startswith("jasmin") else "odoo",
|
||||
"external_type": external_type,
|
||||
"title": f"{external_type} · {number}",
|
||||
"customer_name": "ACZCO BRAGA ENERGY, LDA",
|
||||
"customer_tax_id": "517249200",
|
||||
"document_number": number,
|
||||
"document_date": date,
|
||||
"amount": amount,
|
||||
"currency": "EUR",
|
||||
"payload": {"record": {"name": number, "invoice_status": "to invoice"}} if external_type == "odoo_sale_order" else {},
|
||||
"operation_suggestions": [],
|
||||
}
|
||||
|
||||
|
||||
def test_process_candidates_explain_reasons_and_risks(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"list_reconciliation_items",
|
||||
lambda *, status, limit, days: [
|
||||
_item("quote-154", "jasmin_quotation", "ORC.ORC2026.154", "2026-06-03", "638.60"),
|
||||
_item("sale-279", "odoo_sale_order", "S00279", "2026-06-05", "638.60"),
|
||||
],
|
||||
)
|
||||
|
||||
candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10)
|
||||
|
||||
assert len(candidates) == 1
|
||||
candidate = candidates[0]
|
||||
assert candidate["review_status"] == "needs_review"
|
||||
assert "cliente fiscal por NIF exato" in candidate["reasons"]
|
||||
assert "valor igual entre documentos" in candidate["reasons"]
|
||||
assert "datas próximas" in candidate["reasons"]
|
||||
assert "venda Odoo sem fatura Jasmin associada" in candidate["risks"]
|
||||
|
||||
|
||||
def test_process_candidates_flag_conflict_when_amounts_differ(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
svc,
|
||||
"list_reconciliation_items",
|
||||
lambda *, status, limit, days: [
|
||||
_item("quote-154", "jasmin_quotation", "ORC.ORC2026.154", "2026-06-03", "638.60"),
|
||||
_item("invoice-80", "jasmin_invoice", "FA2026.80", "2026-06-05", "190.00"),
|
||||
],
|
||||
)
|
||||
|
||||
candidates = svc.list_reconciliation_process_candidates(status="open", days=7, limit=10)
|
||||
|
||||
assert len(candidates) == 2 # different amounts keep separate purchase anchors
|
||||
assert all("reasons" in candidate and "risks" in candidate for candidate in candidates)
|
||||
|
||||
|
||||
def test_v4924_schema_and_ui_have_decisions_review_and_history_actions():
|
||||
service = read("app/reconciliation_service.py")
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
|
||||
assert "CREATE TABLE IF NOT EXISTS reconciliation_decisions" in service
|
||||
assert "def _candidate_reasons_and_risks" in service
|
||||
assert "reasons" in service and "risks" in service and "review_status" in service
|
||||
assert "/reconciliation/processes/needs-review" in page
|
||||
assert "/reconciliation/processes/historical" in page
|
||||
assert "Motivos" in page and "Riscos" in page
|
||||
assert "cf-reconcile-explain" in styles
|
||||
24
tests/test_v4925_2_odoo_customer_field_compat.py
Normal file
24
tests/test_v4925_2_odoo_customer_field_compat.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v4925_2_odoo_customer_sync_discovers_schema_before_search_read():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
assert "def _odoo_model_field_names" in sync
|
||||
assert "fields_get" in sync
|
||||
assert "def _odoo_partner_fields_for_available_schema" in sync
|
||||
assert "def _odoo_partner_domains_for_available_schema" in sync
|
||||
assert "ignored_optional_fields" in sync
|
||||
|
||||
|
||||
def test_v4925_2_mobile_is_optional_for_odoo_partner_sync():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
helper = sync.split("def _odoo_partner_fields_for_available_schema", 1)[1].split("def _odoo_partner_domains_for_available_schema", 1)[0]
|
||||
assert '"mobile"' in helper
|
||||
assert 'if field != "mobile"' in helper
|
||||
assert "Invalid field 'mobile'" in sync
|
||||
26
tests/test_v4925_3_odoo_customer_identity_guard.py
Normal file
26
tests/test_v4925_3_odoo_customer_identity_guard.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v4925_3_odoo_sale_order_reference_never_becomes_customer_name():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
assert "def _looks_like_odoo_sale_order_reference" in sync
|
||||
assert "S\\d{4,}" in sync
|
||||
assert "def _odoo_fiscal_customer_name" in sync
|
||||
seed = sync.split("def _customer_seed_data_from_record", 1)[1].split("def _upsert_fiscal_customer_from_external_record", 1)[0]
|
||||
assert 'source_system == "odoo"' in seed
|
||||
assert "_odoo_fiscal_customer_name(record)" in seed
|
||||
assert "Never create/update a fiscal customer with an Odoo sale order reference" in seed
|
||||
|
||||
|
||||
def test_v4925_3_includes_repair_script_for_existing_s00_customers():
|
||||
script = read("scripts/repair_odoo_sale_order_customer_names.py")
|
||||
assert "Dry-run by default" in script
|
||||
assert "metadata->>'source_system' = 'odoo'" in script
|
||||
assert "partner_name" in script
|
||||
assert "--apply" in script
|
||||
57
tests/test_v4925_fiscal_enrichment_worker.py
Normal file
57
tests/test_v4925_fiscal_enrichment_worker.py
Normal file
@@ -0,0 +1,57 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v4925_has_autonomous_fiscal_enrichment_service_and_schema():
|
||||
service = read("app/fiscal_enrichment_service.py")
|
||||
assert "def ensure_fiscal_enrichment_schema" in service
|
||||
assert "external_company_cache" in service
|
||||
assert "fiscal_customer_suggestions" in service
|
||||
assert "fiscal_enrichment_runs" in service
|
||||
assert "def enrich_open_opportunities" in service
|
||||
assert "def enrich_opportunity" in service
|
||||
assert "contactos/search" in service
|
||||
assert "empresas/domain" in service
|
||||
assert "empresas/nif" in service
|
||||
assert "POST" in service or "_http_json(\"POST\"" in service or "batch" in service
|
||||
|
||||
|
||||
def test_v4925_pipeline_runs_enrichment_before_reconciliation_sync():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
block = sync.split("async def sync_all_external_reconciliation_candidates", 1)[1]
|
||||
assert "enrich_open_opportunities" in block
|
||||
assert block.index("enrich_open_opportunities") < block.index("sync_external_fiscal_customers_for_reconciliation")
|
||||
assert "enrichment_auto_applied" in block
|
||||
assert "enrichment_suggested" in block
|
||||
|
||||
|
||||
def test_v4925_opportunity_creation_triggers_best_effort_enrichment():
|
||||
service = read("app/opportunity_service.py")
|
||||
block = service.split("def upsert_opportunity_for_task", 1)[1].split("def advance_opportunity_after_task_done", 1)[0]
|
||||
assert "from app.fiscal_enrichment_service import enrich_opportunity" in block
|
||||
assert "enrich_opportunity(opportunity_id, apply_safe=True)" in block
|
||||
assert "except Exception" in block
|
||||
|
||||
|
||||
def test_v4925_ui_exposes_enrichment_actions():
|
||||
opp = read("app/admin_ui/pages/opportunities.py")
|
||||
recon = read("app/admin_ui/pages/reconciliation.py")
|
||||
assert "/opportunities/{opportunity_id}/fiscal-enrich" in opp
|
||||
assert "/fiscal-suggestions/{suggestion_id}/accept" in opp
|
||||
assert "Sugestões fiscais" in opp
|
||||
assert "/reconciliation/enrich-fiscal" in recon
|
||||
assert "Enriquecer oportunidades" in recon
|
||||
|
||||
|
||||
def test_v4925_scripts_and_settings_exist():
|
||||
assert (ROOT / "scripts/enrich_fiscal_customers.py").exists()
|
||||
assert (ROOT / "scripts/run_reconciliation_pipeline.py").exists()
|
||||
config = read("app/config.py")
|
||||
assert "external_company_lookup_enabled" in config
|
||||
assert "external_company_lookup_base_url" in config
|
||||
assert "external_company_lookup_api_key" in config
|
||||
124
tests/test_v4926_1_email_identity_quality.py
Normal file
124
tests/test_v4926_1_email_identity_quality.py
Normal file
@@ -0,0 +1,124 @@
|
||||
from app.email_identity_extraction_service import extract_email_identity
|
||||
|
||||
|
||||
DIETIMPORT_EMAIL = """
|
||||
Boa tarde,
|
||||
|
||||
Conseguiria enviar-me a fatura em formato digital, referente à nossa última encomenda?
|
||||
|
||||
Obrigado.
|
||||
|
||||
Com os melhores cumprimentos,
|
||||
|
||||
Filipe Delgado
|
||||
|
||||
Engenheiro Civil
|
||||
|
||||
Responsável
|
||||
Departamento de Manutenção
|
||||
|
||||
Rua Dr. Costa Sacadura N.º4 1800-176 Lisboa
|
||||
Telem: +351 96 873 88 56 (Chamada para Rede Móvel Nacional)
|
||||
Tel: +351 21 159 01 95 (Chamada para Rede Fixa Nacional)
|
||||
|
||||
AVISO
|
||||
Esta mensagem (incluindo quaisquer anexos) pode conter informação confidencial para uso exclusivo do destinatário. Se não for o destinatário pretendido não deverá usar, distribuir ou copiar este e-mail. Se recebeu esta mensagem por engano por favor informe o emissor e elimine-a imediatamente. As opiniões emitidas nesta mensagem não são necessariamente as da Dietimport S.A..
|
||||
|
||||
Obrigado.
|
||||
"""
|
||||
|
||||
|
||||
def test_regex_identity_ignores_valediction_as_person_name():
|
||||
data = extract_email_identity(
|
||||
DIETIMPORT_EMAIL,
|
||||
email="filipedelgado@dietimport.pt",
|
||||
use_llm=False,
|
||||
)
|
||||
assert data["person_name"] == "Filipe Delgado"
|
||||
assert data["person_name"] != "Com os melhores cumprimentos,"
|
||||
|
||||
|
||||
def test_regex_identity_keeps_company_name_not_full_disclaimer():
|
||||
data = extract_email_identity(
|
||||
DIETIMPORT_EMAIL,
|
||||
email="filipedelgado@dietimport.pt",
|
||||
use_llm=False,
|
||||
)
|
||||
assert data["company_mentions"] == ["Dietimport S.A"]
|
||||
assert all("informação confidencial" not in company for company in data["company_mentions"])
|
||||
assert all(len(company) < 80 for company in data["company_mentions"])
|
||||
|
||||
|
||||
def test_regex_identity_address_stops_before_phone_labels():
|
||||
data = extract_email_identity(
|
||||
DIETIMPORT_EMAIL,
|
||||
email="filipedelgado@dietimport.pt",
|
||||
use_llm=False,
|
||||
)
|
||||
assert data["address"] == "Rua Dr. Costa Sacadura N.º4 1800-176 Lisboa"
|
||||
assert "Telem" not in data["address"]
|
||||
assert "+351 96 873 88 56" in data["phones"]
|
||||
assert data["domain"] == "dietimport.pt"
|
||||
|
||||
|
||||
def test_identity_cleanup_removes_legal_paragraph_mentions(monkeypatch):
|
||||
from app.email_identity_extraction_service import merge_identity
|
||||
|
||||
regex_data = {
|
||||
"person_name": "",
|
||||
"company_mentions": [
|
||||
"dos nesta comunicação estão incorporados num ficheiro cujo responsável é a SOPAC, S.A",
|
||||
"obter a confirmação sobre se a SOPAC, S.A",
|
||||
],
|
||||
"address": "",
|
||||
"phones": [],
|
||||
"websites": [],
|
||||
"email": "info@adp-fertilizantes.pt",
|
||||
"domain": "adp-fertilizantes.pt",
|
||||
"confidence": 0.9,
|
||||
"evidence": [],
|
||||
"method": "regex",
|
||||
}
|
||||
data = merge_identity(regex_data, None)
|
||||
assert data["company_mentions"] == ["SOPAC, S.A"]
|
||||
assert all("ficheiro" not in company for company in data["company_mentions"])
|
||||
|
||||
|
||||
def test_identity_cleanup_removes_cjk_noise_from_person_name():
|
||||
from app.email_identity_extraction_service import merge_identity
|
||||
|
||||
regex_data = {
|
||||
"person_name": "Luis Roch游戏副本a",
|
||||
"company_mentions": [],
|
||||
"address": "",
|
||||
"phones": [],
|
||||
"websites": [],
|
||||
"email": "luis.rocha@coimpack.pt",
|
||||
"domain": "coimpack.pt",
|
||||
"confidence": 0.85,
|
||||
"evidence": [],
|
||||
"method": "regex",
|
||||
}
|
||||
data = merge_identity(regex_data, None)
|
||||
assert data["person_name"] == "Luis Rocha"
|
||||
assert data["confidence"] <= 0.45
|
||||
|
||||
|
||||
def test_identity_cleanup_rejects_mobile_footer_as_person_name():
|
||||
from app.email_identity_extraction_service import merge_identity
|
||||
|
||||
regex_data = {
|
||||
"person_name": "Enviado do meu Galaxy",
|
||||
"company_mentions": [],
|
||||
"address": "",
|
||||
"phones": [],
|
||||
"websites": [],
|
||||
"email": "paulo.campelo@sapo.pt",
|
||||
"domain": "sapo.pt",
|
||||
"confidence": 0.7,
|
||||
"evidence": [],
|
||||
"method": "regex",
|
||||
}
|
||||
data = merge_identity(regex_data, None)
|
||||
assert data["person_name"] == ""
|
||||
assert data["confidence"] <= 0.45
|
||||
31
tests/test_v4926_5_manual_opportunity_static.py
Normal file
31
tests/test_v4926_5_manual_opportunity_static.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_assisted_identity_skips_duplicate_current_customer_suggestion():
|
||||
source = Path("app/fiscal_enrichment_service.py").read_text()
|
||||
assert "email_identity_matches_current_fiscal_customer" in source
|
||||
assert "suggested_customer_id == linked_customer_id" in source
|
||||
assert '"suggested": 0' in source
|
||||
|
||||
|
||||
def test_customer_detail_has_create_opportunity_flow():
|
||||
source = Path("app/admin_ui/pages/customers.py").read_text()
|
||||
assert "/customers/{customer_id}/opportunities/create" in source
|
||||
assert "create_manual_opportunity_from_customer" in source
|
||||
assert "Criar oportunidade" in source
|
||||
|
||||
|
||||
def test_manual_opportunity_command_links_customer_and_optional_task():
|
||||
source = Path("app/opportunity_service.py").read_text()
|
||||
assert "def create_manual_opportunity_from_customer" in source
|
||||
assert "local_customer_id" in source
|
||||
assert "created_from_customer_page" in source
|
||||
assert "manual_opportunity_created" in source
|
||||
assert "created_from_manual_opportunity" in source
|
||||
|
||||
|
||||
def test_duplicate_identity_metadata_jsonb_parameters_are_cast_to_text():
|
||||
source = Path("app/fiscal_enrichment_service.py").read_text()
|
||||
assert "CAST(:customer_id AS TEXT)" in source
|
||||
assert "CAST(:customer_name AS TEXT)" in source
|
||||
assert "CAST(:customer_tax_id AS TEXT)" in source
|
||||
39
tests/test_v4926_6_2_jasmin_money_extraction_static.py
Normal file
39
tests/test_v4926_6_2_jasmin_money_extraction_static.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text()
|
||||
|
||||
|
||||
def test_jasmin_money_object_helper_exists():
|
||||
source = read("app/reconciliation_service.py")
|
||||
assert "def _jasmin_money_value" in source
|
||||
assert '"amount", "baseAmount", "reportingAmount", "value"' in source
|
||||
assert "value = _jasmin_money_value(value)" in source
|
||||
|
||||
|
||||
def test_jasmin_document_totals_read_real_amount_fields():
|
||||
source = read("app/reconciliation_service.py")
|
||||
assert "def _jasmin_document_totals" in source
|
||||
assert '"grossValueAmount"' in source
|
||||
assert '"taxExclusiveAmountAmount"' in source
|
||||
assert '"taxTotalAmount"' in source
|
||||
assert '"payableAmountAmount"' in source
|
||||
assert '"payableAmount"' in source
|
||||
|
||||
|
||||
def test_jasmin_line_prices_read_nested_and_scalar_fields():
|
||||
source = read("app/reconciliation_service.py")
|
||||
assert '"unitPriceAmount"' in source
|
||||
assert '"unitPrice"' in source
|
||||
assert '"lineExtensionAmountAmount"' in source
|
||||
assert '"lineExtensionAmount"' in source
|
||||
assert '"grossValueAmount"' in source
|
||||
|
||||
|
||||
def test_backfill_script_accepts_money_objects_from_detail_fetch():
|
||||
source = read("scripts/backfill_jasmin_opportunity_details.py")
|
||||
assert "def _money_value" in source
|
||||
assert "value = _money_value(value)" in source
|
||||
31
tests/test_v4926_6_jasmin_reconciliation_static.py
Normal file
31
tests/test_v4926_6_jasmin_reconciliation_static.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_jasmin_reconciliation_imports_documents_and_lines():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
assert "def _upsert_jasmin_document_from_item" in source
|
||||
assert "commercial_documents" in source
|
||||
assert "commercial_document_lines" in source
|
||||
assert "def _jasmin_document_lines_from_item" in source
|
||||
|
||||
|
||||
def test_jasmin_reconciliation_imports_opportunity_items_and_value():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
assert "def _upsert_opportunity_items_from_jasmin_item" in source
|
||||
assert "metadata->>'source_system' = 'jasmin'" in source
|
||||
assert "_apply_jasmin_documents_to_opportunity" in source
|
||||
assert "jasmin_reconciliation_imported" in source
|
||||
|
||||
|
||||
def test_create_and_link_process_apply_jasmin_import():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
assert "_apply_jasmin_documents_to_opportunity(conn, items, opportunity_id, actor=actor)" in source
|
||||
assert "_apply_jasmin_documents_to_opportunity(conn, [item], opportunity_id, actor=actor)" in source
|
||||
|
||||
|
||||
def test_jasmin_reimport_updates_existing_opportunity_items():
|
||||
source = Path("app/reconciliation_service.py").read_text()
|
||||
assert "UPDATE opportunity_items" in source
|
||||
assert "unit_price = CAST(:unit_price AS NUMERIC)" in source
|
||||
assert "total_price = CAST(:total_price AS NUMERIC)" in source
|
||||
assert "price_source" in source
|
||||
20
tests/test_v4926_7_identity_and_customer_guards.py
Normal file
20
tests/test_v4926_7_identity_and_customer_guards.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from app.email_identity_extraction_service import _sanitize_company_mention, is_plausible_company_mention
|
||||
from app.fiscal_enrichment_service import _company_name_matches_mention
|
||||
|
||||
|
||||
def test_rejects_tld_and_domain_fragments_as_company_mentions():
|
||||
for value in ["pt", "com", "www", "sementenergias.pt", "http", "email"]:
|
||||
assert _sanitize_company_mention(value) == ""
|
||||
assert not is_plausible_company_mention(value)
|
||||
|
||||
|
||||
def test_keeps_valid_portuguese_company_mentions():
|
||||
assert _sanitize_company_mention("SEMENTENERGIAS, UNIPESSOAL, LDA")
|
||||
assert is_plausible_company_mention("Dietimport S.A")
|
||||
|
||||
|
||||
def test_short_mentions_do_not_match_inside_company_names():
|
||||
assert not _company_name_matches_mention("pt", "planetoption lda")
|
||||
assert not _company_name_matches_mention("pt", "sementenergias unipessoal lda")
|
||||
assert _company_name_matches_mention("sementenergias unipessoal lda", "sementenergias unipessoal lda")
|
||||
assert _company_name_matches_mention("dietimport s a", "dietimport s a")
|
||||
33
tests/test_v4928_decisional_ui_static.py
Normal file
33
tests/test_v4928_decisional_ui_static.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_reconciliation_decision_service_exists_and_is_read_only():
|
||||
text = Path("app/reconciliation_decision_service.py").read_text()
|
||||
assert "classify_reconciliation_item" in text
|
||||
assert "classify_reconciliation_process" in text
|
||||
assert "reconciliation_decision_summary" in text
|
||||
assert "CREATE UNIQUE INDEX" not in text
|
||||
|
||||
|
||||
def test_reconciliation_page_exposes_decision_lanes_and_actions():
|
||||
text = Path("app/admin_ui/pages/reconciliation.py").read_text()
|
||||
assert "cf-reconcile-decision-grid" in text
|
||||
assert "Ação recomendada" in text
|
||||
assert "/reconciliation/{item_id}/needs-review" in text
|
||||
assert "/reconciliation/processes/ignore" in text
|
||||
assert "sort_items_for_operator" in text
|
||||
|
||||
|
||||
def test_opportunity_page_has_operational_map():
|
||||
text = Path("app/admin_ui/pages/opportunities.py").read_text()
|
||||
assert "Mapa operacional" in text
|
||||
assert "Cliente fiscal" in text
|
||||
assert "Documento principal" in text
|
||||
assert "Ações avançadas" in text
|
||||
|
||||
|
||||
def test_jasmin_actions_avoid_duplicate_linking_when_current_doc_exists():
|
||||
text = Path("app/admin_dashboard.py").read_text()
|
||||
assert "Já existe documento atual. A associação direta fica bloqueada" in text
|
||||
assert "invoice_source_exists" in text
|
||||
assert "É necessário um orçamento ou pró-forma atual para converter" in text
|
||||
63
tests/test_v492_external_api_sync.py
Normal file
63
tests/test_v492_external_api_sync.py
Normal file
@@ -0,0 +1,63 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v492_external_sync_service_stages_candidates_not_opportunities():
|
||||
service = read("app/external_reconciliation_sync.py")
|
||||
|
||||
assert "sync_jasmin_reconciliation_candidates" in service
|
||||
assert "sync_odoo_reconciliation_candidates" in service
|
||||
assert "sync_packlink_reconciliation_candidates" in service
|
||||
assert "upsert_reconciliation_item" in service
|
||||
assert "create_opportunity" not in service
|
||||
assert "CONFIRM_PAYMENT" in service
|
||||
assert "não" not in service.lower() or "confirm" in service.lower()
|
||||
|
||||
|
||||
def test_v492_jasmin_odoo_packlink_types_are_supported():
|
||||
service = read("app/external_reconciliation_sync.py")
|
||||
reconciliation = read("app/reconciliation_service.py")
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
|
||||
assert "jasmin_quotation" in service
|
||||
assert "jasmin_invoice" in service
|
||||
assert "odoo_sale_order" in service
|
||||
assert "packlink_shipment" in service
|
||||
assert "packlink_shipment" in reconciliation
|
||||
assert "Envio Packlink" in page
|
||||
|
||||
|
||||
def test_v492_reconciliation_page_has_external_sync_actions():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
|
||||
assert 'action="/reconciliation/sync-jasmin"' in page
|
||||
assert 'action="/reconciliation/sync-odoo"' in page
|
||||
assert 'action="/reconciliation/sync-packlink"' in page
|
||||
assert 'action="/reconciliation/sync-external"' in page
|
||||
assert "Sincronizar APIs externas" in page
|
||||
|
||||
|
||||
def test_v492_sync_script_exists_and_reports_json_summary():
|
||||
script = read("scripts/sync_external_reconciliation.py")
|
||||
|
||||
assert "--all" in script
|
||||
assert "--jasmin" in script
|
||||
assert "--odoo" in script
|
||||
assert "--packlink" in script
|
||||
assert "created_or_updated" in script
|
||||
assert "json.dumps" in script
|
||||
|
||||
|
||||
def test_v492_documentation_describes_operator_confirmation():
|
||||
doc = read("docs/CLIENTFLOW_V492_EXTERNAL_API_SYNC.md")
|
||||
|
||||
assert "External API Sync" in doc
|
||||
assert "não cria oportunidades automaticamente" in doc.lower()
|
||||
assert "não confirma pagamentos" in doc.lower()
|
||||
assert "operador decide" in doc.lower()
|
||||
assert "systemd" in doc
|
||||
38
tests/test_v493_jasmin_recent_sync.py
Normal file
38
tests/test_v493_jasmin_recent_sync.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v493_jasmin_sync_respects_days_and_global_limit():
|
||||
service = read("app/external_reconciliation_sync.py")
|
||||
script = read("scripts/sync_external_reconciliation.py")
|
||||
|
||||
assert "sync_jasmin_reconciliation_candidates(*, limit: int = 100, days: int = 3)" in service
|
||||
assert "since =" in service
|
||||
assert "_within_days(record, days=days)" in service
|
||||
assert "staged.sort" in service
|
||||
assert "staged[:limit]" in service
|
||||
assert "sync_jasmin_reconciliation_candidates(limit=args.limit, days=args.days)" in script
|
||||
|
||||
|
||||
def test_v493_jasmin_client_supports_odata_filter_and_orderby():
|
||||
client = read("app/jasmin_client.py")
|
||||
|
||||
assert "filter: Optional[str] = None" in client
|
||||
assert "orderby: Optional[str] = None" in client
|
||||
assert 'params["$filter"] = filter' in client
|
||||
assert 'params["$orderby"] = orderby' in client
|
||||
|
||||
|
||||
def test_v493_stale_reconciliation_cleanup_script_exists():
|
||||
script = read("scripts/cleanup_stale_reconciliation_items.py")
|
||||
|
||||
assert "--jasmin" in script
|
||||
assert "--days" in script
|
||||
assert "--apply" in script
|
||||
assert "cleanup_reconciliation_outside_window" in script
|
||||
assert "Dry-run" in script
|
||||
27
tests/test_v494_reconciliation_recent_window.py
Normal file
27
tests/test_v494_reconciliation_recent_window.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v494_external_sync_defaults_to_three_day_window():
|
||||
script = read("scripts/sync_external_reconciliation.py")
|
||||
service = read("app/external_reconciliation_sync.py")
|
||||
|
||||
assert 'parser.add_argument("--days", type=int, default=3' in script
|
||||
assert "sync_all_external_reconciliation_candidates(*, limit: int = 100, days: int = 3)" in service
|
||||
assert "sync_odoo_reconciliation_candidates(*, limit: int = 100, days: int = 3)" in service
|
||||
assert "sync_packlink_reconciliation_candidates(*, limit: int = 100, days: int = 3)" in service
|
||||
|
||||
|
||||
def test_v494_reconciliation_ui_has_cleanup_window_action():
|
||||
ui = read("app/admin_ui/pages/reconciliation.py")
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "/reconciliation/cleanup-window" in ui
|
||||
assert "Limpar fora dos 3 dias" in ui
|
||||
assert "cleanup_reconciliation_outside_window" in service
|
||||
assert "fora da janela operacional" in service
|
||||
35
tests/test_v495_reconciliation_operation_suggestions.py
Normal file
35
tests/test_v495_reconciliation_operation_suggestions.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v495_reconciliation_service_suggests_open_operations():
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "find_open_operation_suggestions_for_reconciliation" in service
|
||||
assert "operation_suggestions" in service
|
||||
assert "operação aberta" in service
|
||||
assert "match_score" in service
|
||||
assert "status = 'pending'" in service
|
||||
assert "o.status = 'open'" in service
|
||||
|
||||
|
||||
def test_v495_reconciliation_ui_prefills_link_actions_from_suggestions():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
|
||||
assert "Sugestão encontrada em Operations" in page
|
||||
assert "Ligar a esta operação" in page
|
||||
assert "name=\"opportunity_id\"" in page
|
||||
assert "Escolher outra oportunidade" in page
|
||||
assert "Criar oportunidade" in page
|
||||
|
||||
|
||||
def test_v495_reconciliation_suggestion_styles_exist():
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
|
||||
assert ".cf-reconcile-suggestion" in styles
|
||||
assert "v4.9.5 reconciliation operation suggestions" in styles
|
||||
42
tests/test_v496_reconciliation_nif_suggestions.py
Normal file
42
tests/test_v496_reconciliation_nif_suggestions.py
Normal file
@@ -0,0 +1,42 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v496_reconciliation_schema_stores_customer_tax_id():
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "customer_tax_id TEXT" in service
|
||||
assert "idx_reconciliation_customer_tax_id" in service
|
||||
assert "customer_tax_id: Optional[str]" in service
|
||||
assert "customer_tax_id = _normalize_tax_id(customer_tax_id)" in service
|
||||
|
||||
|
||||
def test_v496_nif_exact_match_is_primary_suggestion_signal():
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "NIF exato" in service
|
||||
assert "COALESCE(lc.tax_id, '') = :customer_tax_id" in service
|
||||
assert "THEN 90 ELSE 0" in service
|
||||
assert "if not any([customer_id, customer_tax_id, customer_email, customer_name, amount_value])" in service
|
||||
|
||||
|
||||
def test_v496_external_sync_extracts_tax_id_from_jasmin_and_odoo():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
|
||||
assert "def _customer_tax_id" in sync
|
||||
assert "companyTaxID" in sync
|
||||
assert "buyerCustomerPartyTaxID" in sync
|
||||
assert '"customer_tax_id": _customer_tax_id(record)' in sync
|
||||
assert 'client.search_read(\n "res.partner"' in sync
|
||||
assert 'record["partner_vat"]' in sync
|
||||
|
||||
|
||||
def test_v496_reconciliation_ui_shows_customer_nif_when_available():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
|
||||
assert "NIF {esc(item.get(\"customer_tax_id\"))}" in page
|
||||
44
tests/test_v498_reconciliation_matching_board_cleanup.py
Normal file
44
tests/test_v498_reconciliation_matching_board_cleanup.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v498_recent_window_uses_human_last_n_days():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "_recent_window_start" in sync
|
||||
assert "days=days - 1" in sync
|
||||
assert "recent_window_start" in service
|
||||
assert "days=days - 1" in service
|
||||
|
||||
|
||||
def test_v498_reconciliation_page_recent_window_and_search_action():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
|
||||
assert "list_reconciliation_items(status=status, external_type=item_type, limit=100, days=recent_days)" in page
|
||||
assert "reconciliation_summary(days=recent_days)" in page
|
||||
assert "Pesquisar oportunidade" in page
|
||||
assert "Escolher outra oportunidade" in page
|
||||
|
||||
|
||||
def test_v498_odoo_sync_enriches_partner_for_matching():
|
||||
sync = read("app/external_reconciliation_sync.py")
|
||||
|
||||
assert 'fields=["id", "vat", "email", "name"]' in sync
|
||||
assert 'record["partner_vat"]' in sync
|
||||
assert 'record["partner_email"]' in sync
|
||||
assert '"customer_email": _customer_email(record)' in sync
|
||||
|
||||
|
||||
def test_v498_opportunity_board_hides_noise_and_uses_action_column():
|
||||
page = read("app/admin_ui/pages/opportunities.py")
|
||||
|
||||
assert "is_noise_operation_item" in page
|
||||
assert "_is_noise_opportunity" in page
|
||||
assert "_opportunity_board_column_for_opportunity" in page
|
||||
assert "SEND_INVOICE" in page and "payment" in page
|
||||
39
tests/test_v499_process_timeline_reconstruction.py
Normal file
39
tests/test_v499_process_timeline_reconstruction.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
def read(path: str) -> str:
|
||||
return (ROOT / path).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_v499_reconciliation_service_groups_process_candidates():
|
||||
service = read("app/reconciliation_service.py")
|
||||
|
||||
assert "PROCESS_STEP_BY_EXTERNAL_TYPE" in service
|
||||
assert "list_reconciliation_process_candidates" in service
|
||||
assert "infer_reconciliation_process_state" in service
|
||||
assert "create_opportunity_from_reconciliation_process" in service
|
||||
assert "link_reconciliation_process_to_opportunity" in service
|
||||
assert "opportunity_reconstructed_from_reconciliation" in service
|
||||
assert "reconciliation_evidence_imported" in service
|
||||
|
||||
|
||||
def test_v499_reconciliation_page_renders_process_cards_and_actions():
|
||||
page = read("app/admin_ui/pages/reconciliation.py")
|
||||
|
||||
assert "Processos candidatos" in page
|
||||
assert "_render_process_candidates" in page
|
||||
assert "Criar oportunidade reconstruída" in page
|
||||
assert "Ligar processo à sugestão" in page
|
||||
assert 'action="/reconciliation/processes/create-opportunity"' in page
|
||||
assert 'action="/reconciliation/processes/link"' in page
|
||||
|
||||
|
||||
def test_v499_reconciliation_styles_and_docs_exist():
|
||||
styles = read("app/admin_ui/styles.py")
|
||||
docs = read("docs/CLIENTFLOW_V499_PROCESS_TIMELINE_RECONSTRUCTION.md")
|
||||
|
||||
assert "v4.9.9 process timeline reconstruction" in styles
|
||||
assert "cf-reconcile-process-card" in styles
|
||||
assert "Process Timeline Reconstruction" in docs
|
||||
308
tests/test_v499_process_timeline_reconstruction_behavior.py
Normal file
308
tests/test_v499_process_timeline_reconstruction_behavior.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""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"] == "SHIPMENT_CREATED"
|
||||
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"] == "SHIPMENT_CREATED"
|
||||
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"])
|
||||
Reference in New Issue
Block a user