Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

View 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"
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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

View 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

View 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

View 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

View 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"}

View 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"}

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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")

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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

View 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"])