diff --git a/backend/flask_app.py b/backend/flask_app.py index 443eaf7..8637b6f 100644 --- a/backend/flask_app.py +++ b/backend/flask_app.py @@ -856,7 +856,7 @@ TABLES = { "sales": ("sales_leads", ["target_customer", "priority", "status", "tenant"]), "proposals": ("business_proposals", ["customer_or_project_name", "version", "description", "status", "created_date", "proposal_type", "notes", "tenant"]), "operations": ("operation_projects", ["project_name", "project_version", "project_type", "project_status", "current_stage", "owner", "target_customer", "customer_need", "expected_contract_amount", "expected_sign_date", "sign_probability", "next_action", "sop_stage", "execution_progress", "current_deliverable", "risks", "notes", "tenant"]), - "products": ("product_versions", ["product_name", "version", "version_goal", "feature_list", "launch_date", "status", "platform", "notes", "tenant"]), + "products": ("product_versions", ["product_name", "version", "version_goal", "priority", "start_date", "plan_date", "dev_done_date", "test_date", "launch_date", "status", "notes", "tenant"]), "finance": ("finance_records", ["month", "project_name", "record_type", "category", "amount", "occurred_date", "notes", "tenant"]), "tasks": ("project_tasks", ["project_id", "phase", "milestone", "task", "owner", "due_date", "blockers", "notes", "status", "sort_order", "priority", "tenant"]), "projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "budget_data"]), @@ -913,6 +913,22 @@ def update_resource(resource, item_id): valid_statuses = ["未开始", "进行中", "已结束"] if not payload["status"] or payload["status"] not in valid_statuses: payload["status"] = "未开始" + # 产品日期约束:4 个时间不能早于启动时间;启动时间不能清空 + if resource == "products": + # 查当前记录的 start_date + cur = _exec(conn, f"SELECT start_date FROM {table} WHERE id=?", (item_id,)) + row = cur.fetchone() + cur.close() + current_start = (row or {}).get("start_date", "") or "" + new_start = payload.get("start_date", current_start) + # 启动时间必填 + if "start_date" in payload and not new_start: + return jsonify({"error": "启动时间为必填项"}), 400 + date_fields = ["plan_date", "dev_done_date", "test_date", "launch_date"] + for f in date_fields: + if f in payload and payload[f] and new_start and payload[f] < new_start: + labels = {"plan_date": "产品方案", "dev_done_date": "研发完成", "test_date": "测试完成", "launch_date": "上线时间"} + return jsonify({"error": f"{labels[f]}不能早于启动时间({new_start})"}), 400 update_cols = [col for col in cols if col in payload] if update_cols: _exec(conn, diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py index 1996674..a62d639 100644 --- a/backend/migrations/__init__.py +++ b/backend/migrations/__init__.py @@ -17,12 +17,13 @@ def run_migrations(): """ 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.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields 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_drop_product_fields() migrate_seed_users() migrate_seed_demo_data() diff --git a/backend/migrations/columns.py b/backend/migrations/columns.py index 81f6ef3..e11b67c 100644 --- a/backend/migrations/columns.py +++ b/backend/migrations/columns.py @@ -37,8 +37,20 @@ def migrate_add_columns(): "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 ''") + _add_column_if_missing(conn, "product_versions", "priority", + "ALTER TABLE product_versions ADD COLUMN priority VARCHAR(10) NOT NULL DEFAULT 'P2'") + _add_column_if_missing(conn, "product_versions", "start_date", + "ALTER TABLE product_versions ADD COLUMN start_date VARCHAR(30) NOT NULL DEFAULT ''") + _add_column_if_missing(conn, "product_versions", "plan_date", + "ALTER TABLE product_versions ADD COLUMN plan_date VARCHAR(30) NOT NULL DEFAULT ''") + _add_column_if_missing(conn, "product_versions", "dev_done_date", + "ALTER TABLE product_versions ADD COLUMN dev_done_date VARCHAR(30) NOT NULL DEFAULT ''") + _add_column_if_missing(conn, "product_versions", "test_date", + "ALTER TABLE product_versions ADD COLUMN test_date VARCHAR(30) NOT NULL DEFAULT ''") + _add_column_if_missing(conn, "product_versions", "devs", + "ALTER TABLE product_versions ADD COLUMN devs VARCHAR(500) NOT NULL DEFAULT ''") + _add_column_if_missing(conn, "product_versions", "testers", + "ALTER TABLE product_versions ADD COLUMN testers VARCHAR(500) NOT NULL DEFAULT ''") # project_tasks 扩展字段 _add_column_if_missing(conn, "project_tasks", "status", diff --git a/backend/migrations/data_fixes.py b/backend/migrations/data_fixes.py index 4729f6d..56dd6c6 100644 --- a/backend/migrations/data_fixes.py +++ b/backend/migrations/data_fixes.py @@ -47,3 +47,27 @@ def migrate_rename_tenant(): conn.commit() finally: conn.close() + + +def migrate_drop_product_fields(): + """删除 product_versions 表的 owner / platform / feature_list 字段""" + from flask_app import db, mysql + + conn = db() + try: + for col in ["owner", "platform", "feature_list"]: + cur = conn.cursor(dictionary=True) + cur.execute("SHOW COLUMNS FROM product_versions LIKE %s", (col,)) + exists = cur.fetchone() + cur.close() + if exists: + try: + cur = conn.cursor() + cur.execute(f"ALTER TABLE product_versions DROP COLUMN {col}") + cur.close() + conn.commit() + print(f"[migrate] product_versions.{col} 列已删除") + except mysql.connector.Error as e: + print(f"[migrate] 删除 {col} 失败: {e}") + finally: + conn.close() diff --git a/static/modules/drawer.js b/static/modules/drawer.js index ca49b02..d0a5469 100644 --- a/static/modules/drawer.js +++ b/static/modules/drawer.js @@ -23,7 +23,7 @@ function openDrawer(resource, id) { ? [["project_name","项目名称"],["owner","负责人"],["expected_sign_date","截止时间"],["expected_contract_amount","金额"],["notes","项目说明"]] : resource === "proposals" ? [["customer_or_project_name","客户/项目"],["proposal_type","方案类型"],["notes","备注"]] - : [["product_name","产品名称"],["version","版本号"],["version_goal","版本目标"],["feature_list","核心功能"],["launch_date","上线日期"],["status","状态"],["notes","备注"]]; + : [["product_name","版本名称"],["version","版本号"],["priority","优先级"],["version_goal","版本目标"],["start_date","启动时间"],["plan_date","产品方案"],["dev_done_date","研发完成"],["test_date","测试完成"],["launch_date","上线时间"],["notes","进展备注"]]; const fieldIcons = { target_customer: "user", priority: "flag", status: "circle-dot", project_name: "briefcase-business", project_version: "git-branch", project_status: "circle-dot", current_stage: "map-pin", @@ -31,29 +31,38 @@ function openDrawer(resource, id) { sign_probability: "percent", sop_stage: "list-checks", execution_progress: "activity", current_deliverable: "package", risks: "alert-triangle", next_action: "arrow-right", product_name: "box", version: "tag", version_goal: "target", feature_list: "list", platform: "layers", - launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building" + launch_date: "calendar", notes: "sticky-note", proposal_type: "tag", customer_or_project_name: "building", + priority: "flag", owner: "user", start_date: "play", plan_date: "file-text", dev_done_date: "check-square", + test_date: "bug", devs: "users", testers: "shield-check" }; const multilineFields = ["customer_need", "current_deliverable", "risks", "next_action", "version_goal", "feature_list", "notes"]; 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}

+ ${resource === "products" ? (() => { + const dDays = (s, e) => { if (!s || !e) return '-'; const d = Math.round((new Date(e) - new Date(s)) / 86400000); return d >= 0 ? d + ' 天' : '-'; }; + return `
+

耗时统计

+
+

总耗时(上线−启动)

${dDays(item.start_date, item.launch_date)}

+

产品耗时(方案−启动)

${dDays(item.start_date, item.plan_date)}

+

研发耗时(研发完成−方案)

${dDays(item.plan_date, item.dev_done_date)}

+

测试耗时(测试完成−研发完成)

${dDays(item.dev_done_date, item.test_date)}

+
+
`; + })() : ""}

属性

${resource === "operations" ? drawerField("map-pin", "当前阶段", "current_stage", "", false, ``) : ""} ${fields.map(([key,label]) => { - if (resource === "products" && key === "feature_list") { - const features = (item[key] || "").split("\n").filter(Boolean); - if (features.length === 0) features.push(""); - return `
${label}
${features.map((f,i) => `
${i+1}.
`).join("")}
`; + if (resource === "products" && key === "priority") { + return `
优先级
状态
`; } - if (resource === "products" && key === "launch_date") { + if (resource === "products" && (key === "start_date" || key === "plan_date" || key === "dev_done_date" || key === "test_date" || key === "launch_date")) { return drawerField("calendar", label, key, item[key], false, ``); } - if (resource === "products" && key === "status") { - return drawerField("circle-dot", label, key, "", false, ``); - } return drawerField(fieldIcons[key] || "circle", label, key, item[key], multilineFields.includes(key)); }).join("")}
@@ -228,6 +237,27 @@ window.deleteFollowup = async (event, followupId, resource, targetId) => { window.saveDrawerField = async (el, resource, id) => { const name = el.name; const value = el.value; + // 产品日期约束 + if (resource === "products") { + const listKey = "products"; + const product = (state.data[listKey] || []).find(x => x.id === id); + if (product) { + // 启动时间必填 + if (name === "start_date" && !value) { + toast("启动时间为必填项", "error"); + el.value = product.start_date || ''; + el.focus(); + return; + } + // 其他 4 个时间不能早于启动时间 + if (["plan_date","dev_done_date","test_date","launch_date"].includes(name) && value && product.start_date && value < product.start_date) { + toast("该时间不能早于启动时间(" + product.start_date + ")", "error"); + el.value = product[name] || ''; + el.focus(); + return; + } + } + } try { await api(`/api/${resource}/${id}`, { method: "PUT", body: JSON.stringify({ data: { [name]: value } }) }); const listKey = { sales: "sales", proposals: "proposals", operations: "operations", products: "products" }[resource]; diff --git a/static/modules/finance.js b/static/modules/finance.js index 40cef25..bd14922 100644 --- a/static/modules/finance.js +++ b/static/modules/finance.js @@ -109,7 +109,7 @@ function renderFinance() {
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月费用",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `
${l}${v}
`).join("")}
-
+