"""市場情報正式 DB 只讀庫存總覽。 本模組只做 read-only count / group by 查詢,不使用 DatabaseManager、 不建立 ORM session、不寫入、不 commit。 """ from sqlalchemy import bindparam, create_engine, text def _table_status(expected_tables, existing_tables, table_counts): existing_set = set(existing_tables) return [ { "table": table_name, "exists": table_name in existing_set, "row_count": table_counts.get(table_name) if table_name in existing_set else None, "status": "loaded" if table_name in existing_set else "missing", } for table_name in expected_tables ] def _planned_table_status(expected_tables): return [ { "table": table_name, "exists": False, "row_count": None, "status": "planned", } for table_name in expected_tables ] def _json_safe(value): if hasattr(value, "isoformat"): return value.isoformat() return value def _row_dict(row): return { key: _json_safe(value) for key, value in dict(row._mapping).items() } def _probe_existing_postgresql(conn, expected_tables): existing_tables = [] for table_name in expected_tables: exists = conn.execute( text( """ SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_schema = ANY (current_schemas(false)) AND table_name = :table_name ) """ ), {"table_name": table_name}, ).scalar() if exists: existing_tables.append(table_name) return existing_tables def _probe_existing_sqlite(conn, expected_tables): rows = conn.execute( text( """ SELECT name FROM sqlite_master WHERE type = 'table' AND name IN :table_names """ ).bindparams(bindparam("table_names", expanding=True)), {"table_names": tuple(expected_tables)}, ).fetchall() return [row[0] for row in rows] def _count_rows(conn, table_name): return conn.execute(text(f'SELECT COUNT(*) FROM "{table_name}"')).scalar() or 0 def _query_rows(conn, sql): return [_row_dict(row) for row in conn.execute(text(sql)).fetchall()] def _query_platform_breakdown(conn, existing_tables): if "market_platforms" not in existing_tables: return [] return _query_rows( conn, """ SELECT code, name, enabled FROM market_platforms ORDER BY code """, ) def _query_campaign_status_breakdown(conn, existing_tables): if "market_campaigns" not in existing_tables: return [] return _query_rows( conn, """ SELECT platform_code, status, COUNT(*) AS campaign_count FROM market_campaigns GROUP BY platform_code, status ORDER BY platform_code, status """, ) def _query_product_activity_summary(conn, existing_tables): if "market_campaign_products" not in existing_tables: return [] return _query_rows( conn, """ SELECT platform_code, is_active, COUNT(*) AS product_count, MAX(last_seen_at) AS latest_seen_at, AVG(discount_rate) AS avg_discount_rate FROM market_campaign_products GROUP BY platform_code, is_active ORDER BY platform_code, is_active DESC """, ) def _query_match_status_breakdown(conn, existing_tables): if "market_product_matches" not in existing_tables: return [] return _query_rows( conn, """ SELECT match_status, COUNT(*) AS match_count, AVG(match_score) AS avg_match_score, MAX(reviewed_at) AS latest_reviewed_at FROM market_product_matches GROUP BY match_status ORDER BY match_status """, ) def _query_alert_review_state_breakdown(conn, existing_tables): if "market_alert_review_queue" not in existing_tables: return [] return _query_rows( conn, """ SELECT review_state, priority_lane, COUNT(*) AS alert_count, AVG(total_score) AS avg_total_score, MAX(updated_at) AS latest_updated_at FROM market_alert_review_queue GROUP BY review_state, priority_lane ORDER BY review_state, priority_lane """, ) def _query_crawler_run_summary(conn, existing_tables): if "market_crawler_runs" not in existing_tables: return [] return _query_rows( conn, """ SELECT platform_code, status, dry_run, COUNT(*) AS run_count, MAX(started_at) AS latest_started_at, MAX(finished_at) AS latest_finished_at, SUM(error_count) AS total_errors FROM market_crawler_runs GROUP BY platform_code, status, dry_run ORDER BY platform_code, status, dry_run DESC """, ) def build_live_db_inventory_preview( expected_tables, *, execute_requested=False, database_url=None, database_type=None, engine=None, ): """建立 market_* 正式 DB 只讀庫存總覽;預設不連 DB。""" expected_tables = list(expected_tables) if not execute_requested: return { "mode": "live_db_inventory_planned", "execute_requested": False, "read_only_query_executed": False, "database_connection_opened": False, "database_session_created": False, "explicit_transaction_opened": False, "database_write_executed": False, "database_commit_executed": False, "migration_executed": False, "external_network_executed": False, "scheduler_attached": False, "expected_tables": expected_tables, "existing_tables": [], "missing_tables": expected_tables, "table_statuses": _planned_table_status(expected_tables), "table_counts": {}, "total_rows": 0, "summary_ready": False, "platform_breakdown": [], "campaign_status_breakdown": [], "product_activity_summary": [], "match_status_breakdown": [], "alert_review_state_breakdown": [], "crawler_run_summary": [], "safety_checks": { "planned_default_no_db_connection": True, "database_write_blocked": True, "migration_not_executed": True, "scheduler_detached": True, "external_network_blocked": True, }, "blocked_reasons": [ "execute_false_planned_only", "inventory_not_loaded", "database_write_still_blocked", ], } from config import DATABASE_PATH, DATABASE_TYPE effective_database_type = (database_type or DATABASE_TYPE or "").lower() effective_database_url = database_url or DATABASE_PATH created_engine = False connection_opened = False try: if engine is None: connect_args = {} if effective_database_type == "postgresql": connect_args = { "connect_timeout": 8, "options": "-c statement_timeout=15000", } engine = create_engine( effective_database_url, isolation_level="AUTOCOMMIT", pool_pre_ping=True, connect_args=connect_args, ) created_engine = True with engine.connect() as conn: connection_opened = True if effective_database_type == "postgresql": existing_tables = _probe_existing_postgresql(conn, expected_tables) else: existing_tables = _probe_existing_sqlite(conn, expected_tables) existing_set = set(existing_tables) table_counts = { table_name: _count_rows(conn, table_name) for table_name in expected_tables if table_name in existing_set } platform_breakdown = _query_platform_breakdown(conn, existing_set) campaign_status_breakdown = _query_campaign_status_breakdown(conn, existing_set) product_activity_summary = _query_product_activity_summary(conn, existing_set) match_status_breakdown = _query_match_status_breakdown(conn, existing_set) alert_review_state_breakdown = _query_alert_review_state_breakdown(conn, existing_set) crawler_run_summary = _query_crawler_run_summary(conn, existing_set) missing_tables = [ table_name for table_name in expected_tables if table_name not in set(existing_tables) ] blocked_reasons = ["database_write_still_blocked"] if missing_tables: blocked_reasons.insert(0, "market_tables_missing") return { "mode": "live_db_inventory_read_only", "execute_requested": True, "read_only_query_executed": True, "database_connection_opened": connection_opened, "database_session_created": False, "explicit_transaction_opened": False, "database_write_executed": False, "database_commit_executed": False, "migration_executed": False, "external_network_executed": False, "scheduler_attached": False, "expected_tables": expected_tables, "existing_tables": existing_tables, "missing_tables": missing_tables, "table_statuses": _table_status(expected_tables, existing_tables, table_counts), "table_counts": table_counts, "total_rows": sum(table_counts.values()), "summary_ready": not missing_tables, "platform_breakdown": platform_breakdown, "campaign_status_breakdown": campaign_status_breakdown, "product_activity_summary": product_activity_summary, "match_status_breakdown": match_status_breakdown, "alert_review_state_breakdown": alert_review_state_breakdown, "crawler_run_summary": crawler_run_summary, "safety_checks": { "read_only_query_executed": True, "database_write_blocked": True, "migration_not_executed": True, "scheduler_detached": True, "external_network_blocked": True, }, "blocked_reasons": blocked_reasons, } except Exception as exc: return { "mode": "live_db_inventory_error", "execute_requested": True, "read_only_query_executed": False, "database_connection_opened": connection_opened, "database_session_created": False, "explicit_transaction_opened": False, "database_write_executed": False, "database_commit_executed": False, "migration_executed": False, "external_network_executed": False, "scheduler_attached": False, "expected_tables": expected_tables, "existing_tables": [], "missing_tables": expected_tables, "table_statuses": _planned_table_status(expected_tables), "table_counts": {}, "total_rows": 0, "summary_ready": False, "platform_breakdown": [], "campaign_status_breakdown": [], "product_activity_summary": [], "match_status_breakdown": [], "alert_review_state_breakdown": [], "crawler_run_summary": [], "safety_checks": { "read_only_query_executed": False, "database_write_blocked": True, "migration_not_executed": True, "scheduler_detached": True, "external_network_blocked": True, }, "blocked_reasons": [ "live_db_inventory_probe_error", "database_write_still_blocked", ], "error_message": str(exc), } finally: if created_engine: engine.dispose()