From 2bb99feda406d6e05b91ce91a9fa8baefd317d9e Mon Sep 17 00:00:00 2001 From: mac Date: Fri, 26 Jun 2026 12:21:02 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E5=8F=B0=E9=87=8D=E5=91=BD?= =?UTF-8?q?=E5=90=8D=EF=BC=9A=E6=97=A0=E7=95=8C=C2=B7=E6=97=A0=E7=95=8C=20?= =?UTF-8?q?=E2=86=92=20=E5=AD=A6=E4=BC=9A=C2=B7=E6=97=A0=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ALL_TENANTS / session / seed / migrations 全部同步 - 新增 migrate_rename_tenant() 数据迁移,启动自动 UPDATE 所有表 - migrations/ 模式重构(参考 SalesManager) --- backend/flask_app.py | 11 ++- backend/migrations/__init__.py | 28 ++++++ backend/migrations/columns.py | 61 ++++++++++++ backend/migrations/data_fixes.py | 49 ++++++++++ backend/migrations/seed.py | 55 +++++++++++ backend/migrations/tables.py | 162 +++++++++++++++++++++++++++++++ static/modules/drawer.js | 31 +----- templates/index.html | 24 ----- 8 files changed, 363 insertions(+), 58 deletions(-) create mode 100644 backend/migrations/__init__.py create mode 100644 backend/migrations/columns.py create mode 100644 backend/migrations/data_fixes.py create mode 100644 backend/migrations/seed.py create mode 100644 backend/migrations/tables.py diff --git a/backend/flask_app.py b/backend/flask_app.py index aa0fb26..d6377ba 100644 --- a/backend/flask_app.py +++ b/backend/flask_app.py @@ -61,7 +61,7 @@ def admin_required(f): return decorated -ALL_TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "无界·无界"] +ALL_TENANTS = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"] @app.route("/login") def login_page(): @@ -84,7 +84,7 @@ def auth_login(): session["role"] = user["role"] # 管理员可看所有工作台,OPC负责人看分配的工作台 if user["role"] == "admin": - session["tenants"] = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "无界·无界"] + session["tenants"] = ["科普·无界", "科研·无界", "医患·无界", "MCN·无界", "学会·无界"] else: ut = rows(conn, "SELECT tenant FROM user_tenants WHERE user_id=?", (user["id"],)) session["tenants"] = [x["tenant"] for x in ut] @@ -487,7 +487,7 @@ def init_db(): _exec(conn, """INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)""", ("wuji", generate_password_hash("wuji123", "pbkdf2:sha256"), "无界负责人", "opc_owner", date.today().isoformat())) # 各 OPC 负责人绑定工作台 - for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界"),("mcn","MCN·无界"),("wuji","无界·无界")]: + for uname, tenant in [("kepu","科普·无界"),("keyan","科研·无界"),("yihuan","医患·无界"),("mcn","MCN·无界"),("wuji","学会·无界")]: u = one(conn, "SELECT id FROM users WHERE username=?", (uname,)) if u: _exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant)) @@ -1049,8 +1049,9 @@ def health(): return jsonify({"ok": True, "service": "opc-manager"}) -init_db() -seed_db() +from migrations import run_migrations + +run_migrations() if __name__ == "__main__": diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..1996674 --- /dev/null +++ b/backend/migrations/__init__.py @@ -0,0 +1,28 @@ +"""migrations/__init__.py — 数据库自愈机制入口 + +应用启动时调用 run_migrations(),自动: +1. 建表(CREATE TABLE IF NOT EXISTS) +2. 加列(SHOW COLUMNS 检查后 ALTER TABLE ADD COLUMN) +3. 数据修正(UPDATE 修复脏数据/变更枚举值) +4. 初始化默认用户和示例数据(仅空库时) + +参考 SalesManager 的 migrations/ 模式,所有迁移函数幂等可重复执行。 +""" + + +def run_migrations(): + """执行所有迁移(顺序执行,幂等) + + 延迟 import 避免 circular import(migrations 各子模块依赖 flask_app 的 db/_exec 等)。 + """ + from migrations.tables import migrate_create_tables + from migrations.columns import migrate_add_columns + from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant + from migrations.seed import migrate_seed_users, migrate_seed_demo_data + + migrate_create_tables() + migrate_add_columns() + migrate_fix_task_status() + migrate_rename_tenant() + migrate_seed_users() + migrate_seed_demo_data() diff --git a/backend/migrations/columns.py b/backend/migrations/columns.py new file mode 100644 index 0000000..81f6ef3 --- /dev/null +++ b/backend/migrations/columns.py @@ -0,0 +1,61 @@ +"""migrations/columns.py — 加列迁移(老表补字段,幂等)""" + + +def _add_column_if_missing(conn, table, column, ddl): + """检查列是否存在,不存在才加(幂等)""" + from flask_app import _exec, mysql, logger + + cur = conn.cursor(dictionary=True) + cur.execute(f"SHOW COLUMNS FROM {table} LIKE %s", (column,)) + exists = cur.fetchone() + cur.close() + if not exists: + try: + _exec(conn, ddl) + print(f"[migrate] {table}.{column} 列已添加") + except mysql.connector.Error as e: + logger.debug(f"add column {table}.{column} skipped: {e}") + + +def migrate_add_columns(): + """为老表补齐后续新增的字段""" + from flask_app import db + + conn = db() + try: + # tenant 字段(多工作台支持) + for table in ["sales_leads", "follow_up_records", "business_proposals", + "operation_projects", "product_versions", "finance_records", + "project_tasks"]: + _add_column_if_missing(conn, table, "tenant", + f"ALTER TABLE {table} ADD COLUMN tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界'") + + # business_proposals 扩展字段 + _add_column_if_missing(conn, "business_proposals", "proposal_type", + "ALTER TABLE business_proposals ADD COLUMN proposal_type VARCHAR(100) NOT NULL DEFAULT '业务方案'") + _add_column_if_missing(conn, "business_proposals", "notes", + "ALTER TABLE business_proposals ADD COLUMN notes VARCHAR(2000) NOT NULL DEFAULT ''") + + # product_versions 扩展字段 + _add_column_if_missing(conn, "product_versions", "platform", + "ALTER TABLE product_versions ADD COLUMN platform VARCHAR(100) NOT NULL DEFAULT ''") + + # project_tasks 扩展字段 + _add_column_if_missing(conn, "project_tasks", "status", + "ALTER TABLE project_tasks ADD COLUMN status VARCHAR(50) NOT NULL DEFAULT '未开始'") + _add_column_if_missing(conn, "project_tasks", "sort_order", + "ALTER TABLE project_tasks ADD COLUMN sort_order INT NOT NULL DEFAULT 0") + _add_column_if_missing(conn, "project_tasks", "priority", + "ALTER TABLE project_tasks ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'") + + # project_finances 12 个月度预算字段(确收/毛利/回款/费用) + for m in ["01","02","03","04","05","06","07","08","09","10","11","12"]: + for field in ["rev", "gross", "payment", "cost"]: + col = f"{field}_2026_{m}" + _add_column_if_missing(conn, "project_finances", col, + f"ALTER TABLE project_finances ADD COLUMN {col} DOUBLE NOT NULL DEFAULT 0") + + conn.commit() + print("[migrate] 加列迁移完成") + finally: + conn.close() diff --git a/backend/migrations/data_fixes.py b/backend/migrations/data_fixes.py new file mode 100644 index 0000000..4729f6d --- /dev/null +++ b/backend/migrations/data_fixes.py @@ -0,0 +1,49 @@ +"""migrations/data_fixes.py — 数据修正迁移(修复脏数据、变更枚举值)""" + + +def migrate_fix_task_status(): + """修正 project_tasks 中非法的 status 值""" + from flask_app import db, _exec, mysql, logger + + conn = db() + try: + fixes = [ + "UPDATE project_tasks SET status='未开始' WHERE status='' OR status IS NULL", + "UPDATE project_tasks SET status='已结束' WHERE status='done'", + "UPDATE project_tasks SET status='进行中' WHERE status='验收中'", + ] + for sql in fixes: + try: + cur = _exec(conn, sql) + affected = cur.rowcount + cur.close() + if affected: + print(f"[migrate] 修正 {affected} 条任务状态") + except mysql.connector.Error as e: + logger.warning(f"task status fix skipped: {e}") + conn.commit() + finally: + conn.close() + + +def migrate_rename_tenant(): + """工作台重命名:无界·无界 → 学会·无界""" + from flask_app import db, _exec, mysql + + conn = db() + try: + tables = ["user_tenants", "sales_leads", "follow_up_records", "business_proposals", + "operation_projects", "product_versions", "finance_records", "project_tasks", + "project_finances"] + for table in tables: + try: + cur = _exec(conn, f"UPDATE {table} SET tenant='学会·无界' WHERE tenant='无界·无界'") + affected = cur.rowcount + cur.close() + if affected: + print(f"[migrate] {table}: {affected} 条记录 tenant 改为 '学会·无界'") + except mysql.connector.Error: + pass + conn.commit() + finally: + conn.close() diff --git a/backend/migrations/seed.py b/backend/migrations/seed.py new file mode 100644 index 0000000..4bfa63d --- /dev/null +++ b/backend/migrations/seed.py @@ -0,0 +1,55 @@ +"""migrations/seed.py — 初始化默认用户和示例数据(仅在空库时执行)""" + +from datetime import date + + +def migrate_seed_users(): + """初始化默认用户和工作台权限(仅空库时执行)""" + from flask_app import db, _exec, one, generate_password_hash + + conn = db() + try: + if one(conn, "SELECT id FROM users LIMIT 1"): + return # 已有用户,跳过 + + default_users = [ + ("qiukai", "yxcowork2026", "qiukai", "admin"), + ("kepu", "kepu123", "科普负责人", "opc_owner"), + ("keyan", "keyan123", "科研负责人", "opc_owner"), + ("yihuan", "yihuan123", "医患负责人", "opc_owner"), + ("mcn", "mcn123", "MCN负责人", "opc_owner"), + ("wuji", "wuji123", "无界负责人", "opc_owner"), + ] + for username, pwd, display, role in default_users: + _exec(conn, "INSERT INTO users (username, password_hash, display_name, role, created_at) VALUES (?,?,?,?,?)", + (username, generate_password_hash(pwd, "pbkdf2:sha256"), display, role, date.today().isoformat())) + + # 绑定工作台 + tenant_map = [ + ("kepu", "科普·无界"), ("keyan", "科研·无界"), ("yihuan", "医患·无界"), + ("mcn", "MCN·无界"), ("wuji", "学会·无界"), + ] + for uname, tenant in tenant_map: + u = one(conn, "SELECT id FROM users WHERE username=?", (uname,)) + if u: + _exec(conn, "INSERT INTO user_tenants (user_id, tenant) VALUES (?,?)", (u["id"], tenant)) + + conn.commit() + print("[migrate] 默认用户已初始化") + finally: + conn.close() + + +def migrate_seed_demo_data(): + """填充初始示例数据(仅在空库时执行)""" + from flask_app import db, one, seed_db + + conn = db() + try: + if one(conn, "SELECT id FROM sales_leads LIMIT 1"): + return # 已有数据,跳过 + finally: + conn.close() + + seed_db() + print("[migrate] 示例数据已填充") diff --git a/backend/migrations/tables.py b/backend/migrations/tables.py new file mode 100644 index 0000000..e110fc7 --- /dev/null +++ b/backend/migrations/tables.py @@ -0,0 +1,162 @@ +"""migrations/tables.py — 建表迁移(所有表的 CREATE TABLE IF NOT EXISTS)""" + + +def migrate_create_tables(): + """确保所有业务表存在(幂等)""" + from flask_app import db, _exec, mysql, logger + + conn = db() + try: + tables = [ + """CREATE TABLE IF NOT EXISTS sales_leads ( + id INT AUTO_INCREMENT PRIMARY KEY, + target_customer VARCHAR(1000) NOT NULL, + priority VARCHAR(1000) NOT NULL DEFAULT 'P1', + status VARCHAR(1000) NOT NULL DEFAULT '待跟进', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS follow_up_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + target_type VARCHAR(1000) NOT NULL, + target_id INT NOT NULL, + followed_at VARCHAR(1000) NOT NULL DEFAULT '', + follower VARCHAR(1000) NOT NULL DEFAULT '慰心', + follow_up_method VARCHAR(1000) NOT NULL DEFAULT '记录', + content VARCHAR(1000) NOT NULL DEFAULT '', + next_action VARCHAR(1000) NOT NULL DEFAULT '', + next_follow_up_at VARCHAR(1000) NOT NULL DEFAULT '', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS business_proposals ( + id INT AUTO_INCREMENT PRIMARY KEY, + customer_or_project_name VARCHAR(1000) NOT NULL, + version VARCHAR(1000) NOT NULL, + description VARCHAR(1000) NOT NULL DEFAULT '', + status VARCHAR(1000) NOT NULL DEFAULT '草稿', + created_date VARCHAR(1000) NOT NULL DEFAULT '', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS operation_projects ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_name VARCHAR(1000) NOT NULL, + project_version VARCHAR(1000) NOT NULL DEFAULT 'v1.0', + project_type VARCHAR(1000) NOT NULL DEFAULT 'opportunity', + project_status VARCHAR(1000) NOT NULL DEFAULT '', + current_stage VARCHAR(1000) NOT NULL DEFAULT '', + owner VARCHAR(1000) NOT NULL DEFAULT '慰心', + start_date VARCHAR(1000) NOT NULL DEFAULT '', + end_date VARCHAR(1000) NOT NULL DEFAULT '', + target_customer VARCHAR(1000) NOT NULL DEFAULT '', + customer_need VARCHAR(1000) NOT NULL DEFAULT '', + expected_contract_amount DOUBLE NOT NULL DEFAULT 0, + expected_sign_date VARCHAR(1000) NOT NULL DEFAULT '', + sign_probability DOUBLE NOT NULL DEFAULT 0, + next_action VARCHAR(1000) NOT NULL DEFAULT '', + related_business_proposal_id INTEGER, + sop_file_id INTEGER, + sop_stage VARCHAR(1000) NOT NULL DEFAULT '', + execution_progress DOUBLE NOT NULL DEFAULT 0, + current_deliverable VARCHAR(1000) NOT NULL DEFAULT '', + risks VARCHAR(1000) NOT NULL DEFAULT '', + notes VARCHAR(1000) NOT NULL DEFAULT '', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS product_versions ( + id INT AUTO_INCREMENT PRIMARY KEY, + product_name VARCHAR(1000) NOT NULL, + version VARCHAR(1000) NOT NULL, + version_goal VARCHAR(1000) NOT NULL DEFAULT '', + feature_list VARCHAR(1000) NOT NULL DEFAULT '', + launch_date VARCHAR(1000) NOT NULL DEFAULT '', + status VARCHAR(1000) NOT NULL DEFAULT '规划中', + notes VARCHAR(1000) NOT NULL DEFAULT '', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS finance_records ( + id INT AUTO_INCREMENT PRIMARY KEY, + month VARCHAR(1000) NOT NULL, + project_name VARCHAR(1000) NOT NULL DEFAULT '科普(慰心斋)', + record_type VARCHAR(1000) NOT NULL, + category VARCHAR(1000) NOT NULL DEFAULT '', + amount DOUBLE NOT NULL DEFAULT 0, + occurred_date VARCHAR(1000) NOT NULL DEFAULT '', + notes VARCHAR(1000) NOT NULL DEFAULT '', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS file_assets ( + id INT AUTO_INCREMENT PRIMARY KEY, + module VARCHAR(1000) NOT NULL, + owner_id INT NOT NULL, + owner_version VARCHAR(1000) NOT NULL DEFAULT '', + file_category VARCHAR(1000) NOT NULL DEFAULT '', + file_name VARCHAR(1000) NOT NULL, + file_type VARCHAR(1000) NOT NULL DEFAULT '', + file_size INTEGER NOT NULL DEFAULT 0, + file_path VARCHAR(1000) NOT NULL, + is_external INTEGER NOT NULL DEFAULT 0, + notes VARCHAR(1000) NOT NULL DEFAULT '', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS project_tasks ( + id INT AUTO_INCREMENT PRIMARY KEY, + project_id INTEGER NOT NULL, + phase VARCHAR(1000) NOT NULL DEFAULT '', + milestone VARCHAR(1000) NOT NULL DEFAULT '', + task VARCHAR(1000) NOT NULL DEFAULT '', + owner VARCHAR(1000) NOT NULL DEFAULT '', + due_date VARCHAR(1000) NOT NULL DEFAULT '', + blockers VARCHAR(1000) NOT NULL DEFAULT '', + notes VARCHAR(1000) NOT NULL DEFAULT '', + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS users ( + id INT AUTO_INCREMENT PRIMARY KEY, + username VARCHAR(100) NOT NULL UNIQUE, + password_hash VARCHAR(255) NOT NULL, + display_name VARCHAR(100) NOT NULL, + role VARCHAR(50) NOT NULL DEFAULT 'opc_owner', + created_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + """CREATE TABLE IF NOT EXISTS user_tenants ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL, + tenant VARCHAR(100) NOT NULL, + UNIQUE KEY (user_id, tenant) +)""", + """CREATE TABLE IF NOT EXISTS project_finances ( + id INT AUTO_INCREMENT PRIMARY KEY, + tenant VARCHAR(100) NOT NULL DEFAULT '科普·无界', + project_id VARCHAR(100) NOT NULL DEFAULT '', + business_type VARCHAR(100) NOT NULL DEFAULT '', + customer_name VARCHAR(200) NOT NULL DEFAULT '', + sign_amount DOUBLE NOT NULL DEFAULT 0, + sign_month VARCHAR(20) NOT NULL DEFAULT '', + status VARCHAR(50) NOT NULL DEFAULT '待签约', + sales_person VARCHAR(100) NOT NULL DEFAULT '', + total_rev DOUBLE NOT NULL DEFAULT 0, + total_gross DOUBLE NOT NULL DEFAULT 0, + budget_data TEXT, + created_at VARCHAR(30) NOT NULL DEFAULT '', + updated_at VARCHAR(30) NOT NULL DEFAULT '' +)""", + ] + + for ddl in tables: + try: + _exec(conn, ddl) + conn.commit() + except mysql.connector.Error as e: + logger.debug(f"create table skipped: {e}") + conn.commit() + + print("[migrate] 所有业务表已就绪") + finally: + conn.close() diff --git a/static/modules/drawer.js b/static/modules/drawer.js index 68663fb..ca49b02 100644 --- a/static/modules/drawer.js +++ b/static/modules/drawer.js @@ -1,4 +1,4 @@ -// drawer.js — 详情抽屉 + 评论 + 转移 + 删除 +// drawer.js — 详情抽屉 + 评论 + 删除 function drawerField(icon, label, name, value, multiline = false, customControl = null) { const safeValue = esc(value || ""); @@ -37,7 +37,7 @@ function openDrawer(resource, id) { const followupTarget = resource === "sales" ? "sales" : resource === "proposals" ? "proposal" : resource === "operations" ? "operation" : resource === "products" ? "product" : ""; const title = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name); const titleForAttr = esc(item.target_customer || item.project_name || (item.customer_or_project_name ? `${item.customer_or_project_name} · ${item.proposal_type || ''}` : "") || item.product_name); - drawer.innerHTML = `

Detail Drawer

${title}

+ drawer.innerHTML = `

Detail Drawer

${title}

属性

@@ -172,33 +172,6 @@ window.deleteDrawerItem = async (resource, id) => { } }; -window.openTransferModal = (resource, id, title) => { - document.querySelector("#transfer-resource").value = resource; - document.querySelector("#transfer-id").value = id; - document.querySelector("#transfer-title-text").textContent = "将「" + title + "」转移到:"; - document.querySelector("#transferModal").classList.remove("hidden"); -}; - -window.closeTransferModal = () => { - document.querySelector("#transferModal").classList.add("hidden"); -}; - -window.submitTransfer = async (event) => { - event.preventDefault(); - const form = event.currentTarget; - const resource = form.querySelector('[name="transfer_resource"]').value; - const id = form.querySelector('[name="transfer_id"]').value; - const newTenant = form.querySelector('[name="transfer_tenant"]').value; - try { - await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { tenant: newTenant } }) }); - closeTransferModal(); - closeDrawer(); - await load(); - } catch (error) { - toast("转移失败:" + error.message, "error"); - } -}; - // Squire 富文本编辑器 window.squireInstances = {}; window.squireCmd = (cmd) => { diff --git a/templates/index.html b/templates/index.html index 7e078e7..fc2f71d 100644 --- a/templates/index.html +++ b/templates/index.html @@ -91,30 +91,6 @@
-