Compare commits

...

2 Commits

Author SHA1 Message Date
mac
adeff08827 Merge branch 'dev'
All checks were successful
Deploy / deploy (push) Successful in 12s
2026-07-02 20:10:45 +08:00
mac
493150cb27 财务项目详情升级:任务管理tab + 项目编号 + 执行信息 + 后端重构
后端重构:
- flask_app.py 拆分为 db.py/helpers.py/routes.py/seed_data.py + Blueprint
- 删除死代码 init_db/latest_followup,净减 240 行
- migrations 反向依赖消除

财务项目详情:
- 新增任务管理 tab(月份/类型/数量/已执行/差额/单价/执行金额/未执行金额)
- 新增项目编号、开始/结束时间、项目经理、合同服务费标准(5%-25%下拉)
- 科普业务类型新增科普专访、患教会
- task_data JSON 存储任务列表

财务视图:
- 只保留总视图和月度视图,去除确收/毛利和回款/应付视图
- 月度视图月份选择器
- 表格统一居中对齐
- 去除流程项目/流程金额卡片
2026-07-02 20:10:45 +08:00
5 changed files with 171 additions and 7 deletions

View File

@@ -17,7 +17,7 @@ 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, migrate_drop_product_fields
from migrations.data_fixes import migrate_fix_task_status, migrate_rename_tenant, migrate_drop_product_fields, migrate_fix_service_fee_standard
from migrations.seed import migrate_seed_users, migrate_seed_demo_data
migrate_create_tables()
@@ -25,5 +25,6 @@ def run_migrations():
migrate_fix_task_status()
migrate_rename_tenant()
migrate_drop_product_fields()
migrate_fix_service_fee_standard()
migrate_seed_users()
migrate_seed_demo_data()

View File

@@ -75,6 +75,24 @@ def migrate_add_columns():
_add_column_if_missing(conn, "project_finances", "total_paid",
"ALTER TABLE project_finances ADD COLUMN total_paid DOUBLE NOT NULL DEFAULT 0")
# project_finances 项目基本信息扩展字段
_add_column_if_missing(conn, "project_finances", "project_code",
"ALTER TABLE project_finances ADD COLUMN project_code VARCHAR(50) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "project_finances", "start_date",
"ALTER TABLE project_finances ADD COLUMN start_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "project_finances", "end_date",
"ALTER TABLE project_finances ADD COLUMN end_date VARCHAR(30) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "project_finances", "task_type",
"ALTER TABLE project_finances ADD COLUMN task_type VARCHAR(100) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "project_finances", "task_count",
"ALTER TABLE project_finances ADD COLUMN task_count DOUBLE NOT NULL DEFAULT 0")
_add_column_if_missing(conn, "project_finances", "service_fee_standard",
"ALTER TABLE project_finances ADD COLUMN service_fee_standard DOUBLE NOT NULL DEFAULT 0")
_add_column_if_missing(conn, "project_finances", "project_manager",
"ALTER TABLE project_finances ADD COLUMN project_manager VARCHAR(100) NOT NULL DEFAULT ''")
_add_column_if_missing(conn, "project_finances", "task_data",
"ALTER TABLE project_finances ADD COLUMN task_data TEXT")
conn.commit()
print("[migrate] 加列迁移完成")
finally:

View File

@@ -71,3 +71,24 @@ def migrate_drop_product_fields():
print(f"[migrate] 删除 {col} 失败: {e}")
finally:
conn.close()
def migrate_fix_service_fee_standard():
"""修正 project_finances.service_fee_standard 旧数据为默认值 55%"""
from db import db
conn = db()
try:
cur = conn.cursor()
cur.execute(
"UPDATE project_finances SET service_fee_standard = 5 "
"WHERE service_fee_standard = 0 OR service_fee_standard IS NULL "
"OR service_fee_standard NOT IN (5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25)"
)
affected = cur.rowcount
cur.close()
if affected:
conn.commit()
print(f"[migrate] project_finances: {affected} 条记录 service_fee_standard 修正为 5")
finally:
conn.close()

View File

@@ -23,7 +23,7 @@ TABLES = {
"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", "total_payment", "total_cost", "total_paid", "budget_data"]),
"projectFinances": ("project_finances", ["project_id", "tenant", "business_type", "customer_name", "sign_amount", "sign_month", "status", "sales_person", "owner", "total_rev", "total_gross", "total_payment", "total_cost", "total_paid", "budget_data", "start_date", "end_date", "task_type", "task_count", "service_fee_standard", "project_manager", "task_data", "project_code"]),
}
# ---------- 鉴权装饰器 ----------

View File

@@ -6,7 +6,7 @@ function renderFinance() {
const pfs = state.data.projectFinances || [];
const ops = state.data.operations || [];
const fmTypesByTenant = {
"科普·无界": ["科普音频","科普视频","科普文章","全品类科普","调研问卷"],
"科普·无界": ["科普音频","科普视频","科普文章","科普专访","患教会","全品类科普","调研问卷"],
"科研·无界": ["真实世界研究","调研问卷","病例征集","患者招募"],
"医患·无界": ["医患运营","患者管理","患教会","创新支付","电商","其他"],
};
@@ -110,26 +110,30 @@ function renderFinance() {
${[["本月确收",moneyInt(thisMonthRev),"trending-up"],["本月毛利",moneyInt(thisMonthGross),"percent"],["本月回款",moneyInt(monthPayment),"wallet"],["本月应付",moneyInt(monthCost),"receipt"],["本月现金流",moneyInt(monthCashflow),"repeat"]].map(([l,v,icon]) => `<div class="metric-card"><span class="flex items-center gap-2 text-xs text-slate-500"><i data-lucide="${icon}" style="width:14px;height:14px"></i>${l}</span><strong class="mt-2 block text-2xl">${v}</strong></div>`).join("")}
</div>
<div class="flex justify-between items-center"><div class="flex items-center gap-1" id="finViewToggle"><button class="btn btn-sm ${state.finView === 'overview' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="overview" onclick="setFinView('overview')" title="总视图"><i data-lucide="layout-dashboard" style="width:16px;height:16px"></i><span class="text-xs ml-1">总视图</span></button><button class="btn btn-sm ${state.finView === 'monthly' ? 'btn-primary' : 'btn-ghost'} px-2 py-1.5" data-view="monthly" onclick="setFinView('monthly')" title="月度视图"><i data-lucide="calendar-days" style="width:16px;height:16px"></i><span class="text-xs ml-1">月度视图</span></button></div><button class="btn btn-primary btn-sm" onclick="openFinanceModal()"><i data-lucide="plus"></i>新增财务项目</button></div>
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-5xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div id="financeModal" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/40" onclick="closeFinanceModal()"><div class="bg-white rounded-2xl shadow-2xl w-full max-w-6xl mx-4 max-h-[92vh] overflow-y-auto" onclick="event.stopPropagation()"><div class="sticky top-0 z-10 bg-white/95 backdrop-blur border-b border-slate-100 px-8 py-5 flex items-center justify-between"><div><h3 class="text-xl font-bold text-slate-800" id="financeModalTitle">新增项目财务</h3><p class="text-xs text-slate-400 mt-0.5">填写项目财务信息与月度预算</p></div><div class="flex items-center gap-2"><button class="btn btn-ghost btn-sm text-red-600 hidden" id="financeDeleteBtn" onclick="deleteFinanceItem()"><i data-lucide="trash-2"></i>删除</button><button class="btn btn-ghost btn-sm rounded-full w-8 h-8 p-0" onclick="closeFinanceModal()"><i data-lucide="x"></i></button></div></div>
<div class="finance-tabs">
<button class="finance-tab active" data-tab="info" onclick="switchFinanceTab('info')"><i data-lucide="info" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>基本信息</button>
<button class="finance-tab" data-tab="budget" onclick="switchFinanceTab('budget')"><i data-lucide="calendar" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>月度流水</button>
<button class="finance-tab" data-tab="tasks" onclick="switchFinanceTab('tasks')"><i data-lucide="list-checks" style="width:14px;height:14px;display:inline-block;vertical-align:-2px;margin-right:4px"></i>任务管理</button>
</div>
<form onsubmit="createFinance(event)" class="p-8 grid gap-6"><input type="hidden" name="pf_id" id="pf-id-input" value="">
<form onsubmit="createFinance(event)" class="p-8 grid gap-6" novalidate><input type="hidden" name="pf_id" id="pf-id-input" value="">
<div id="financeTabInfo">
<div class="grid grid-cols-2 gap-5">
<div class="fin-field-group">
<p class="fin-section-label">项目信息</p>
<div class="grid gap-4">
<label class="block"><span class="fin-label">部门</span><input type="hidden" name="project_id" value="${state.tenant}"><input class="form-ctrl bg-slate-50 cursor-not-allowed" value="${state.tenant}" disabled></label>
<label class="block"><span class="fin-label">业务类型</span><select name="business_type" class="form-ctrl bg-white">${fmTypes.map(t => `<option>${t}</option>`).join("")}</select></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="fin-label">项目编号</span><input name="project_code" class="form-ctrl" placeholder="如KP-2026-001"></label>
<label class="block"><span class="fin-label">业务类型</span><select name="business_type" class="form-ctrl bg-white">${fmTypes.map(t => `<option>${t}</option>`).join("")}</select></label>
</div>
<label class="block"><span class="fin-label">项目名称 <span class="text-red-500">*</span></span><input name="customer_name" required class="form-ctrl" placeholder="请输入项目名称"></label>
</div>
</div>
<div class="fin-field-group">
<p class="fin-section-label">合同信息</p>
<div class="grid gap-4">
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" type="number" step="0.01" min="0.01" required class="form-ctrl" placeholder="必须大于 0"></label>
<label class="block"><span class="fin-label">签约金额(元) <span class="text-red-500">*</span></span><input name="sign_amount" class="form-ctrl" placeholder="必须大于 0"></label>
<div class="grid grid-cols-2 gap-3">
<label class="block"><span class="fin-label">签约月份 <span class="text-red-500">*</span></span><select name="sign_month" required class="form-ctrl bg-white"><option value="">选择</option>${monthOptions('')}</select></label>
<label class="block"><span class="fin-label">项目状态</span><select name="status" class="form-ctrl bg-white"><option>已签约</option><option>流程中</option><option>待签约</option></select></label>
@@ -141,6 +145,15 @@ function renderFinance() {
</div>
</div>
</div>
<div class="fin-field-group mt-5">
<p class="fin-section-label">执行信息</p>
<div class="grid grid-cols-3 gap-4">
<label class="block"><span class="fin-label">开始时间</span><input name="start_date" type="date" class="form-ctrl"></label>
<label class="block"><span class="fin-label">结束时间</span><input name="end_date" type="date" class="form-ctrl"></label>
<label class="block"><span class="fin-label">项目经理</span><input name="project_manager" class="form-ctrl" placeholder="请输入项目经理"></label>
<label class="block"><span class="fin-label">合同服务费标准</span><select name="service_fee_standard" class="form-ctrl bg-white">${Array.from({length:21},(_,i)=>{const v=i+5;return `<option value="${v}" ${v===5?'selected':''}>${v}%</option>`}).join("")}</select></label>
</div>
</div>
</div>
<div id="financeTabBudget" class="hidden">
<div class="grid grid-cols-5 gap-3 mb-4" id="budgetSummary">
@@ -171,6 +184,13 @@ function renderFinance() {
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addBudgetRow()"><i data-lucide="plus"></i></button>
</div>
<div id="financeTabTasks" class="hidden">
<table class="w-full text-sm border border-slate-200 rounded-lg overflow-hidden" id="taskTable">
<thead><tr class="bg-slate-50 border-b border-slate-200"><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px">月份</th><th class="p-2.5 text-left font-medium text-slate-500" style="min-width:120px"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 text-right font-medium text-slate-500"></th><th class="p-2.5 w-8"></th></tr></thead>
<tbody id="taskTbody"></tbody>
</table>
<button type="button" class="btn btn-ghost btn-sm mt-3" onclick="addTaskRow()"><i data-lucide="plus"></i></button>
</div>
<div class="flex justify-end gap-3 pt-2"><button type="button" class="btn btn-ghost btn-sm px-6" onclick="closeFinanceModal()">取消</button><button type="submit" class="btn btn-primary btn-sm px-8"></button></div></form></div></div>
${state.finView === 'monthly' ? (() => {
const allPfs = pfs.filter(x => x.status === state.finFilter);
@@ -230,6 +250,7 @@ window.openFinanceModal = () => {
const pfIdInput = form.querySelector('[name="pf_id"]');
if (!pfIdInput || !pfIdInput.value) {
initBudgetTable(null);
initTaskTable(null);
document.querySelector("#financeDeleteBtn").classList.add("hidden");
}
modal.classList.remove("hidden");
@@ -284,6 +305,74 @@ window.initBudgetTable = (budgetData) => {
setTimeout(() => updateBudgetSummary(), 50);
};
// ---------- 任务管理 tab ----------
const TASK_TYPES = ["科普视频", "科普专访", "科普文章"];
function taskMonthOptions(selected) {
const now = new Date();
const opts = [];
for (let i = -2; i <= 12; i++) {
const d = new Date(now.getFullYear(), now.getMonth() + i, 1);
const v = d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0");
opts.push(`<option value="${v}" ${v === selected ? 'selected' : ''}>${v}</option>`);
}
return opts.join("");
}
window.addTaskRow = (taskMonth = '', taskType = '', taskCount = '', executedCount = '', unitPrice = '') => {
const tbody = document.querySelector("#taskTbody");
if (!tbody) return;
const row = document.createElement("tr");
const defaultMonth = (() => { const n = new Date(); return n.getFullYear() + "-" + String(n.getMonth()+1).padStart(2,"0"); })();
row.innerHTML = `<td><select name="task_month[]" class="form-ctrl form-ctrl-sm w-full">${taskMonthOptions(taskMonth || defaultMonth)}</select></td>
<td><select name="task_type[]" class="form-ctrl form-ctrl-sm w-full" onchange="updateTaskDiff(this)"><option value="">选择</option>${TASK_TYPES.map(t => `<option ${t === taskType ? 'selected' : ''}>${t}</option>`).join("")}</select></td>
<td><input name="task_count[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${taskCount}" oninput="updateTaskDiff(this)"></td>
<td><input name="task_executed[]" type="number" step="1" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:100px" placeholder="0" value="${executedCount}" oninput="updateTaskDiff(this)"></td>
<td><input name="task_diff[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:80px" placeholder="—" disabled></td>
<td><input name="task_unit_price[]" type="number" step="0.01" min="0" class="form-ctrl form-ctrl-sm text-right" style="width:90px" placeholder="0" value="${unitPrice}" oninput="updateTaskDiff(this)"></td>
<td><input name="task_exec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></td>
<td><input name="task_unexec_amount[]" type="number" class="form-ctrl form-ctrl-sm text-right" style="width:140px" placeholder="—" disabled></td>
<td><button type="button" class="btn btn-ghost btn-sm text-red-500 p-0 w-6 h-6" onclick="this.closest('tr').remove()"><i data-lucide="trash-2" style="width:14px;height:14px"></i></button></td>`;
tbody.appendChild(row);
if (window.lucide) window.lucide.createIcons();
updateRowCalc(row);
};
window.updateTaskDiff = (el) => {
const row = el.closest('tr');
if (!row) return;
updateRowCalc(row);
};
function updateRowCalc(row) {
const countInput = row.querySelector('[name="task_count[]"]');
const execInput = row.querySelector('[name="task_executed[]"]');
const priceInput = row.querySelector('[name="task_unit_price[]"]');
const diffInput = row.querySelector('[name="task_diff[]"]');
const execAmtInput = row.querySelector('[name="task_exec_amount[]"]');
const unexecAmtInput = row.querySelector('[name="task_unexec_amount[]"]');
const c = parseFloat(countInput.value) || 0;
const e = parseFloat(execInput.value) || 0;
const p = parseFloat(priceInput.value) || 0;
const diff = c - e;
// 差额
diffInput.value = (!c && !e) ? '' : diff;
// 执行金额 = 单价 * 已执行
const execAmt = p * e;
execAmtInput.value = execAmt ? execAmt.toFixed(2) : '';
// 未执行金额 = 单价 * 差额
const unexecAmt = p * diff;
unexecAmtInput.value = unexecAmt ? unexecAmt.toFixed(2) : '';
}
window.initTaskTable = (taskData) => {
const tbody = document.querySelector("#taskTbody");
if (!tbody) return;
tbody.innerHTML = "";
const rows = taskData || [];
rows.forEach(r => addTaskRow(r.task_month || '', r.task_type || '', r.task_count || '', r.task_executed || '', r.unit_price || ''));
};
window.closeFinanceModal = () => {
const modal = document.querySelector("#financeModal");
modal.classList.add("hidden");
@@ -320,6 +409,7 @@ window.switchFinanceTab = (tab) => {
document.querySelectorAll(".finance-tab").forEach(b => b.classList.toggle("active", b.dataset.tab === tab));
document.querySelector("#financeTabInfo").classList.toggle("hidden", tab !== "info");
document.querySelector("#financeTabBudget").classList.toggle("hidden", tab !== "budget");
document.querySelector("#financeTabTasks").classList.toggle("hidden", tab !== "tasks");
};
window.openPfEditModal = (pfId) => {
@@ -334,6 +424,8 @@ window.openPfEditModal = (pfId) => {
if (deptDisplay) deptDisplay.value = pf.project_id || "";
form.querySelector('[name="business_type"]').value = pf.business_type || "";
form.querySelector('[name="customer_name"]').value = pf.customer_name || "";
const setVal = (name, val) => { const el = form.querySelector(`[name="${name}"]`); if (el) el.value = val || ""; };
setVal("project_code", pf.project_code);
form.querySelector('[name="sign_amount"]').value = pf.sign_amount || "";
const signMonthValue = pf.sign_month || "";
const signMonthEl = form.querySelector('[name="sign_month"]');
@@ -344,9 +436,18 @@ window.openPfEditModal = (pfId) => {
form.querySelector('[name="status"]').value = pf.status || "待签约";
form.querySelector('[name="sales_person"]').value = pf.sales_person || "";
form.querySelector('[name="owner"]').value = pf.owner || "";
setVal("start_date", pf.start_date);
setVal("end_date", pf.end_date);
setVal("task_type", pf.task_type);
setVal("task_count", pf.task_count);
setVal("service_fee_standard", pf.service_fee_standard || 5);
setVal("project_manager", pf.project_manager);
let budgetData = [];
try { budgetData = JSON.parse(pf.budget_data || "[]"); } catch (e) { budgetData = []; }
initBudgetTable(budgetData.length ? budgetData : null);
let taskData = [];
try { taskData = JSON.parse(pf.task_data || "[]"); } catch (e) { taskData = []; }
initTaskTable(taskData.length ? taskData : null);
setTimeout(() => updateBudgetSummary(), 100);
openFinanceModal();
};
@@ -392,6 +493,29 @@ window.createFinance = async (event) => {
for (const r of budgetRows) { totalPayment += r.payment; totalCost += r.cost; }
data.total_payment = totalPayment;
data.total_cost = totalCost;
// 收集任务管理数据
const taskTypeInputs = form.querySelectorAll('[name="task_type[]"]');
const taskCountInputs = form.querySelectorAll('[name="task_count[]"]');
const taskExecInputs = form.querySelectorAll('[name="task_executed[]"]');
const taskRows = [];
for (let i = 0; i < taskTypeInputs.length; i++) {
const tt = taskTypeInputs[i].value.trim();
if (!tt) continue;
taskRows.push({
task_month: form.querySelectorAll('[name="task_month[]"]')[i].value || '',
task_type: tt,
task_count: parseFloat(taskCountInputs[i].value) || 0,
task_executed: parseFloat(taskExecInputs[i].value) || 0,
unit_price: parseFloat(form.querySelectorAll('[name="task_unit_price[]"]')[i].value) || 0,
});
}
data.task_data = JSON.stringify(taskRows);
// 清除数组命名的字段FormData 会收集 task_type[] 等),避免后端写入不存在的列
delete data.task_type;
delete data.task_count;
for (const key of Object.keys(data)) {
if (key.endsWith('[]')) delete data[key];
}
const pfId = data.pf_id;
delete data.pf_id;
try {