"""Rule-group schema, bootstrap, and sync helpers.""" from __future__ import annotations from typing import Any from sqlalchemy import bindparam, text async def ensure_rule_group_schema(session) -> None: """Create the new rule-group tables when they do not exist yet.""" statements = [ """ CREATE TABLE IF NOT EXISTS leaudit_evaluation_point_groups ( id BIGSERIAL PRIMARY KEY, pid BIGINT NOT NULL DEFAULT 0, code VARCHAR(120) NOT NULL, name VARCHAR(200) NOT NULL, description TEXT NULL, document_type_id BIGINT NULL REFERENCES leaudit_document_types(id), entry_module_id BIGINT NULL REFERENCES leaudit_entry_modules(id), sort_order INTEGER NOT NULL DEFAULT 0, is_enabled BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ NULL ) """, """ CREATE TABLE IF NOT EXISTS leaudit_rule_group_bindings ( id BIGSERIAL PRIMARY KEY, group_id BIGINT NOT NULL REFERENCES leaudit_evaluation_point_groups(id), rule_set_id BIGINT NOT NULL REFERENCES leaudit_rule_sets(id), rule_type_binding_id BIGINT NULL REFERENCES leaudit_rule_type_bindings(id), tenant_code VARCHAR(64) NULL, scope_type VARCHAR(32) NOT NULL DEFAULT 'PROVINCIAL', tenant_name_snapshot VARCHAR(255) NULL, priority INTEGER NOT NULL DEFAULT 0, is_active BOOLEAN NOT NULL DEFAULT TRUE, note TEXT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), deleted_at TIMESTAMPTZ NULL ) """, "CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_pid ON leaudit_evaluation_point_groups(pid)", "CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_doc_type ON leaudit_evaluation_point_groups(document_type_id)", "CREATE INDEX IF NOT EXISTS idx_leaudit_ep_groups_entry_module ON leaudit_evaluation_point_groups(entry_module_id)", "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_group_id ON leaudit_rule_group_bindings(group_id)", "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_rule_set_id ON leaudit_rule_group_bindings(rule_set_id)", "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_group_tenant ON leaudit_rule_group_bindings(group_id, tenant_code) WHERE deleted_at IS NULL", "CREATE INDEX IF NOT EXISTS idx_leaudit_rule_group_bindings_scope_type ON leaudit_rule_group_bindings(scope_type) WHERE deleted_at IS NULL", "CREATE UNIQUE INDEX IF NOT EXISTS uq_leaudit_ep_groups_code_active ON leaudit_evaluation_point_groups (LOWER(code)) WHERE deleted_at IS NULL", "DROP INDEX IF EXISTS uq_leaudit_ep_groups_doc_type_active", "DROP INDEX IF EXISTS uq_leaudit_rule_group_bindings_active", "CREATE UNIQUE INDEX IF NOT EXISTS uq_leaudit_rule_group_bindings_group_scope_rule_set_active ON leaudit_rule_group_bindings (group_id, COALESCE(NULLIF(BTRIM(tenant_code), ''), 'PROVINCIAL'), rule_set_id) WHERE deleted_at IS NULL", ] for statement in statements: await session.execute(text(statement)) async def bootstrap_rule_groups(session) -> None: """Seed doc-type roots and default child groups from current doc-type bindings.""" await ensure_rule_group_schema(session) # Once the system has entered "business root" mode, stop recreating the # historical "doc type as top root" structure on every request. business_root_exists = bool( ( await session.execute( text( """ SELECT 1 FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND document_type_id IS NULL LIMIT 1 """ ) ) ).scalar_one_or_none() ) if business_root_exists: return rows = ( await session.execute( text( """ SELECT dt.id, dt.code, dt.name, dt.description, dt.entry_module_id, dt.sort_order, dt.is_enabled, em.name AS entry_module_name, COALESCE( json_agg( json_build_object( 'id', b.id, 'rule_set_id', b.rule_set_id, 'priority', b.priority, 'is_active', b.is_active, 'note', b.note ) ORDER BY b.priority DESC, b.id ASC ) FILTER (WHERE b.id IS NOT NULL AND b.deleted_at IS NULL), '[]'::json ) AS bindings FROM leaudit_document_types dt LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id LEFT JOIN leaudit_rule_type_bindings b ON b.doc_type_id = dt.id AND b.deleted_at IS NULL WHERE dt.deleted_at IS NULL GROUP BY dt.id, dt.code, dt.name, dt.description, dt.entry_module_id, dt.sort_order, dt.is_enabled, em.name ORDER BY dt.sort_order ASC, dt.id ASC """ ) ) ).mappings().all() for row in rows: top_group_id = await ensure_top_group(session, row) child_group_id = await ensure_default_child_group(session, row, top_group_id) existing_binding_count = int( ( await session.execute( text( "SELECT COUNT(*) FROM leaudit_rule_group_bindings WHERE group_id = :group_id AND deleted_at IS NULL" ), {"group_id": child_group_id}, ) ).scalar_one() ) bindings = list(row.get("bindings") or []) if existing_binding_count == 0 and bindings: await _replace_group_bindings(session, child_group_id, int(row["id"]), bindings) async def ensure_group_for_doc_type(session, doc_type_id: int) -> dict[str, int]: """Ensure the tree nodes for one doc type exist and return their ids.""" row = ( await session.execute( text( """ SELECT dt.id, dt.code, dt.name, dt.description, dt.entry_module_id, dt.sort_order, dt.is_enabled, em.name AS entry_module_name FROM leaudit_document_types dt LEFT JOIN leaudit_entry_modules em ON em.id = dt.entry_module_id WHERE dt.deleted_at IS NULL AND dt.id = :doc_type_id LIMIT 1 """ ), {"doc_type_id": doc_type_id}, ) ).mappings().first() if not row: raise ValueError("文档类型不存在") business_root_exists = bool( ( await session.execute( text( """ SELECT 1 FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND document_type_id IS NULL LIMIT 1 """ ) ) ).scalar_one_or_none() ) if business_root_exists: target_root_code = "root.contract" if int(row.get("entry_module_id") or 0) == 1 else "root.casefile" if int(row.get("entry_module_id") or 0) == 2 else None if target_root_code: root_row = ( await session.execute( text( """ SELECT id FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND code = :code LIMIT 1 """ ), {"code": target_root_code}, ) ).mappings().first() if root_row: child_row = ( await session.execute( text( """ SELECT id FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND pid = :pid AND document_type_id = :doc_type_id ORDER BY sort_order ASC, id ASC LIMIT 1 """ ), {"pid": int(root_row["id"]), "doc_type_id": doc_type_id}, ) ).mappings().first() if child_row: return {"top_group_id": int(root_row["id"]), "child_group_id": int(child_row["id"])} top_group_id = await ensure_top_group(session, row) child_group_id = await ensure_default_child_group(session, row, top_group_id) return {"top_group_id": top_group_id, "child_group_id": child_group_id} async def sync_group_bindings_from_doc_type(session, doc_type_id: int, rule_set_ids: list[int]) -> int: """Mirror doc-type bindings into the child rule-group bindings.""" await ensure_rule_group_schema(session) ids = [int(item) for item in rule_set_ids if item] ids = list(dict.fromkeys(ids)) group_ids = await ensure_group_for_doc_type(session, doc_type_id) child_group_id = group_ids["child_group_id"] payload: list[dict[str, Any]] = [] for index, rule_set_id in enumerate(ids): payload.append( { "rule_set_id": rule_set_id, "id": None, "priority": 100 - index, "is_active": True, "note": None, } ) await _replace_group_bindings(session, child_group_id, doc_type_id, payload) return child_group_id async def sync_doc_type_bindings_from_group(session, group_id: int) -> int | None: """兼容空实现:新链路已直接读取分组绑定,不再反向写旧文档类型绑定表。""" await ensure_rule_group_schema(session) group_row = ( await session.execute( text( """ SELECT g.id, g.document_type_id, dt.code AS document_type_code FROM leaudit_evaluation_point_groups g LEFT JOIN leaudit_document_types dt ON dt.id = g.document_type_id WHERE g.id = :group_id AND g.deleted_at IS NULL LIMIT 1 """ ), {"group_id": group_id}, ) ).mappings().first() if not group_row or group_row.get("document_type_id") is None: return None return int(group_row["document_type_id"]) async def ensure_top_group(session, doc_type_row) -> int: """Create or reuse the top-level root group for one doc type.""" doc_type_id = int(doc_type_row["id"]) top_code = str(doc_type_row["code"]) top_name = str(doc_type_row["name"]) top_sort = int(doc_type_row.get("sort_order") or 0) top = ( await session.execute( text( """ SELECT id FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND COALESCE(pid, 0) = 0 AND document_type_id = :doc_type_id LIMIT 1 """ ), {"doc_type_id": doc_type_id}, ) ).mappings().first() if top: await session.execute( text( """ UPDATE leaudit_evaluation_point_groups SET code = :code, name = :name, description = :description, sort_order = :sort_order, is_enabled = :is_enabled, updated_at = NOW() WHERE id = :group_id """ ), { "group_id": int(top["id"]), "code": top_code, "name": top_name, "description": doc_type_row.get("description"), "sort_order": top_sort, "is_enabled": bool(doc_type_row.get("is_enabled", True)), }, ) return int(top["id"]) legacy = ( await session.execute( text( """ SELECT id FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND document_type_id = :doc_type_id AND LOWER(code) = LOWER(:code) ORDER BY CASE WHEN COALESCE(pid, 0) = 0 THEN 0 ELSE 1 END, id ASC LIMIT 1 """ ), {"doc_type_id": doc_type_id, "code": top_code}, ) ).mappings().first() if legacy: await session.execute( text( """ UPDATE leaudit_evaluation_point_groups SET pid = 0, code = :code, name = :name, description = :description, document_type_id = :doc_type_id, sort_order = :sort_order, is_enabled = :is_enabled, updated_at = NOW() WHERE id = :group_id """ ), { "group_id": int(legacy["id"]), "doc_type_id": doc_type_id, "code": top_code, "name": top_name, "description": doc_type_row.get("description"), "sort_order": top_sort, "is_enabled": bool(doc_type_row.get("is_enabled", True)), }, ) return int(legacy["id"]) inserted = ( await session.execute( text( """ INSERT INTO leaudit_evaluation_point_groups ( pid, code, name, description, document_type_id, sort_order, is_enabled, created_at, updated_at ) VALUES ( 0, :code, :name, :description, :doc_type_id, :sort_order, :is_enabled, NOW(), NOW() ) RETURNING id """ ), { "doc_type_id": doc_type_id, "code": top_code, "name": top_name, "description": doc_type_row.get("description"), "sort_order": top_sort, "is_enabled": bool(doc_type_row.get("is_enabled", True)), }, ) ).mappings().one() return int(inserted["id"]) async def ensure_default_child_group(session, doc_type_row, top_group_id: int) -> int: """Ensure there is at least one default second-level group under a doc-type root.""" doc_type_id = int(doc_type_row["id"]) child = ( await session.execute( text( """ SELECT id, pid FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND pid = :pid AND document_type_id = :doc_type_id AND LOWER(code) = LOWER(:code) LIMIT 1 """ ), {"pid": top_group_id, "doc_type_id": doc_type_id, "code": f"{doc_type_row['code']}.default"}, ) ).mappings().first() any_children = int( ( await session.execute( text( """ SELECT COUNT(*) FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND pid = :pid """ ), {"pid": top_group_id}, ) ).scalar_one() ) if child: return int(child["id"]) if any_children > 0: adopted = ( await session.execute( text( """ SELECT id FROM leaudit_evaluation_point_groups WHERE deleted_at IS NULL AND pid = :pid AND document_type_id = :doc_type_id ORDER BY sort_order ASC, id ASC LIMIT 1 """ ), {"pid": top_group_id, "doc_type_id": doc_type_id}, ) ).mappings().first() if adopted: return int(adopted["id"]) payload = { "pid": top_group_id, "code": f"{doc_type_row['code']}.default", "name": "通用", "description": f"{doc_type_row['name']}默认子类型", "document_type_id": doc_type_id, "sort_order": int(doc_type_row.get("sort_order") or 0), "is_enabled": bool(doc_type_row.get("is_enabled", True)), } inserted = ( await session.execute( text( """ INSERT INTO leaudit_evaluation_point_groups ( pid, code, name, description, document_type_id, sort_order, is_enabled, created_at, updated_at ) VALUES ( :pid, :code, :name, :description, :document_type_id, :sort_order, :is_enabled, NOW(), NOW() ) RETURNING id """ ), payload, ) ).mappings().one() default_child_id = int(inserted["id"]) await _move_root_bindings_to_default_child(session, top_group_id, default_child_id) return int(inserted["id"]) async def _move_root_bindings_to_default_child(session, root_group_id: int, child_group_id: int) -> None: await session.execute( text( """ UPDATE leaudit_rule_group_bindings SET group_id = :child_group_id, updated_at = NOW() WHERE group_id = :root_group_id AND deleted_at IS NULL """ ), {"root_group_id": root_group_id, "child_group_id": child_group_id}, ) async def _replace_group_bindings(session, child_group_id: int, doc_type_id: int, bindings: list[dict[str, Any]]) -> None: await session.execute( text( "UPDATE leaudit_rule_group_bindings SET deleted_at = NOW(), updated_at = NOW() WHERE group_id = :group_id AND deleted_at IS NULL" ), {"group_id": child_group_id}, ) for item in bindings: await session.execute( text( """ INSERT INTO leaudit_rule_group_bindings ( group_id, rule_set_id, rule_type_binding_id, priority, is_active, note, created_at, updated_at ) VALUES ( :group_id, :rule_set_id, :rule_type_binding_id, :priority, :is_active, :note, NOW(), NOW() ) """ ), { "group_id": child_group_id, "rule_set_id": int(item["rule_set_id"]), "rule_type_binding_id": int(item["id"]) if item.get("id") else None, "priority": int(item.get("priority") or 0), "is_active": bool(item.get("is_active", True)), "note": item.get("note"), }, ) await sync_doc_type_bindings_from_group(session, child_group_id)