diff --git a/.workbuddy/memory/2026-09-16.md b/.workbuddy/memory/2026-09-16.md new file mode 100644 index 0000000..d4c5c59 --- /dev/null +++ b/.workbuddy/memory/2026-09-16.md @@ -0,0 +1,48 @@ +# 2026-09-16 + +## 拉取代码仓 +- 从 `https://git.unissense.tech/mula/unis_manager` 克隆代码到本地工作目录 `C:\Users\Administrator\WorkBuddy\unis_manager`。 +- 当前分支 `main`,最新提交 `e9854eb v 0.0.1`。直接克隆进空的工作目录(`git clone .`)。 +- 项目结构:FastAPI 后端(`app/`:main/config/models/schemas/routers/ai_parser/export 等)+ 原生 JS 前端(`web/`)+ SQLite 数据(`data/board.db`)+ 运维脚本(`scripts/`)。 +- 快速启动:`./run.sh`。依赖见 `requirements.txt`(fastapi / uvicorn / sqlalchemy / pydantic / httpx / openpyxl / python-multipart)。 + +## 本地启动(Windows) +- `run.sh` 是为 macOS 写的(用了 `open`、`/Users/jiliu/...` 路径),Windows 上不能直接跑,需手动执行: + 1. 装依赖:`C:/Users/Administrator/.workbuddy/binaries/python/envs/default/Scripts/python.exe -m pip install -r requirements.txt`(已安装成功) + 2. 启动:`.../python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8770` +- `data/board.db` 已有数据,无需 init/import。 +- 验证通过:`/health` 返回 `{"ok":true,"db":"sqlite"}`,首页 200,`/api/weekly` 正常返回周期与项目数据(当前周期「2026年9月第3周」)。 +- 服务运行中:http://127.0.0.1:8770 + +## 切换分支到 develop-chezhiqi +- `git fetch --all` 拉到远端新分支 `origin/develop-chezhiqi`。 +- `git checkout -b develop-chezhiqi origin/develop-chezhiqi` → 已建本地跟踪分支并切换。 +- 该分支目前与 `main` 指向同一提交 `e9854eb v 0.0.1`(无代码差异),是后续开发的落脚分支。 +- 注:`.workbuddy/memory/` 下新增的每日日志与 MEMORY.md 属未跟踪文件,切分支时会保留。 + +## 在 develop-chezhiqi 分支上重启服务 +- 停掉旧进程(占用 8770 的 PID 8732;git bash 下 `taskkill //PID` 参数会被改写,改用 PowerShell `Stop-Process` 更稳)。 +- 重新启动:`uvicorn app.main:app --host 127.0.0.1 --port 8770`,新 boot id `dfafea7f...`,`/health` ok、首页 200。 +- 当前运行分支:`develop-chezhiqi`。 + +## 术语改名:定制交付 → 定开项目 +用户要求把模块③的称呼「定制交付」统一改成「定开项目」(公司内部正式叫法就是“定开”,见 `import_excel.py` 里 `/Users/jiliu/WorkSpace/定开管理/` 路径)。范围确认为**全量统一** + **项目分类一起改**。 + +替换规则(顺序敏感,带排除): +1. `定制交付项目` → `定开项目`(先做,否则会出现“定开项目项目”) +2. `定制交付` → `定开项目` +3. `定制项目` → `定开项目` +4. `定制(?!开发|产品组)` → `定开` + +共改 21 个文件、137 行,涉及 `web/{index.html,app.js,styles.css,icons.js}`、`app/**`、`scripts/**`、`run.sh`、`README.md`。界面用词已统一:定开项目 / 定开项目明细 / 定开合同额 / 定开记录 / 定开动态 / 定开待计收。 + +**数据库**:`categories` 表 id=4 的 `name` 由「定制交付」改为「定开项目」(关联 3 个项目)。改前备份 `data/board.db.bak-before-rename-*`。 + +**刻意保留不动的字面量**(改了会破坏数据/逻辑): +- `"定制开发"` —— 历史 Excel 里的项目容器名,出现在 `import_excel.py:KEY_PROJECTS`、`migrate_custom.py:CONTAINER`、`fix_period_split.py`、`add_entry_date.py` 的匹配条件里。 +- `import_excel.py:47` 的元组键 `("定制","交付","项目支撑")` —— 匹配 Excel 原始分组名,只改了映射目标值。 +- `constants.py:STAFF_GROUPS` 里的 `"定制产品组"` —— 团队名,`staff.group` 里有 7 人是这个值。 +- 业务数据(`projects.name` / `tasks.name` / `updates.content` / `staff.note` / `custom_projects.name`)一律未动。 +- 代码标识符(`custom` / `custom_projects` / API 路由 / CSS 类名)未动。 + +验证:`/api/categories` 返回 id=4 名称「定开项目」;首页 200;import 自检通过;服务已重启(boot `274bba9d...`)。 diff --git a/.workbuddy/memory/2026-09-17.md b/.workbuddy/memory/2026-09-17.md new file mode 100644 index 0000000..80f6f19 --- /dev/null +++ b/.workbuddy/memory/2026-09-17.md @@ -0,0 +1,127 @@ +# 2026-09-17 + +## 清空「定开项目」模块数据 +按用户要求清空 `data/board.db` 里定开项目模块的全部数据(保留表结构与其余模块)。 + +- 备份:`data/board.db.bak-before-clear-custom-20260917_105824` +- 删除顺序:先子表 `custom_updates`(28 行)→ 再父表 `custom_projects`(119 行),避免外键孤儿。 +- 清空前摘要:119 个定开项目(归档 0),合同额合计约 395.2 万,累计计收 0。 +- 清空后校验:两表均 0 行,`custom_updates` 孤儿记录 0。 +- 接口验证:`/api/custom/projects` 返回 `[]`;`/api/weekly` 的 `custom` 段 0 条,KPI 中 `custom_total / custom_amount / custom_new / custom_updated / revenue_delta` 全部归零。项目管理模块(`projects`/`tasks`/`updates`)未受影响。 +- `ledger_items`(定开台账)本来就是 0 行,未动。 + +## 导入《定开项目.xlsx》市场台账 + 扩展 custom_projects 字段 +用户提供 `C:/Users/Administrator/Desktop/定开项目.xlsx`(Sheet1,120 行含表头 → **119 条数据**,14 列),要求导入定开项目库、按表格调整字段、并加「更新时间」字段便于按周查看变化。经确认采用:**新增字段(不重构)** + **每行一个更新时间** + **项目状态新增独立字段**。 + +### 表结构(10 个新字段,靠 `schema_patch.ensure_columns` 自动 ALTER TABLE) +`origin`(项目归属) / `project_code`(项目ID, index) / `office`(办事处) / `industry`(行业) / `man_days`(市场下单人天) / `market_owner`(市场责任人) / `project_status`(项目状态) / `accept_plan`(推动验收计划) / `risk_level`(风险值) / `update_date`(更新时间 YYYY-MM-DD, index) + +### 字段映射(表格列 → DB 列) +- 项目归属→origin、项目名称→name、项目ID→project_code、行业→industry、市场下单人天→man_days、市场责任人→market_owner、项目状态→project_status、推动验收计划→accept_plan、风险值→risk_level、备注→remark +- **下单时间→`sign_date`**(原字段注释就是「签单/下单时间」);**办事处→`region`(归一化)+ `office`(原样)**;**验收时间→`accept_date`** +- `entry_date`(入库时间)兜底取下单时间,保证明细列表「按入库时间倒序」有排序键 + +### 数据质量(表格里就有的问题,未擅自改) +- 重名 2 行:「甘肃省水利厅图像站建设项目二期」×2,**项目ID 不同**(HGSE25122401N / HGS025010202N)→ 导入脚本**有 ID 时只按 ID 匹配**,否则会互相覆盖 +- 1 行无项目ID(高新区感知源-钰田(定制开发))、1 行无办事处 +- 「验收时间/推动验收计划」是**季度文本**(Q1..Q4/27年),互补:60+59=119 +- 「下单时间」混格式:91 个真日期 + 28 个文本(`2024年3月前`、`2024/`)→ 日期转 YYYY-MM-DD,文本原样存 +- 「市场责任人」51 人**全部不在花名册**(销售侧人员,与 `owner` 交付负责人是两个概念) +- 只有「深圳」不在区域词表 → 已在 `constants.REGION_ALIAS` 加 `深圳/深圳市 → 广东` + +### 改动文件 +- `app/models.py`:CustomProject 加 10 字段 + `to_dict` 输出 +- `app/routers/custom.py`:PATCH 白名单加新字段;**台账字段有变化时刷新 `update_date`**(无变化不刷新,已实测);创建时写 `update_date`;`CustomProjectIn` 补字段 +- `app/routers/export.py`:导出追加 10 列(末尾追加,保持原 money_cols 索引不变) +- `web/app.js`:明细列表 16→25 列(加 项目ID/行业/项目归属/市场责任人/项目状态/风险值/人天/推动验收/更新时间);新增 `statusPill`/`riskPill` 小色块;详情抽屉加「市场台账」区块(只读 + 编辑);KPI 提示加 人天/高风险 +- `scripts/import_custom_ledger.py`:**新建**,openpyxl 读取 + 按 ID/名称 upsert + 变更比对,支持 `--dry-run` / `--date` + +### 验证 +- 导入:新增 119 / 更新 0 / 无变化 0;**重复导入幂等**(0/0/119) +- 分布与源表一致:项目状态 已验收60/待开发31/交付完成17/交付中9/退单2;行业 安平51/政府25/企业22/教育11/交通6/医疗2/行业1/高速1 +- `/api/custom/projects` 返回 119 条含全部新字段;导出 34 列、合计行人天 5066 +- PATCH 改 industry → update_date 由 2026-01-01 刷新为今天;提交相同值 → 保持旧日期(变更检测正确),测试后已还原 +- 备份:`data/board.db.bak-before-ledger-import-20260917_141759` + +### 遗留 / 待确认 +- 119 行 `stage` 全是默认 `signed`、`progress` 0、计收/开票/回款为空 —— 新台账不带这些业务字段,模块里依赖它们的旧列(进度%、可确认、已计收、开票、回款)目前是空的 +- 页面未做浏览器截图验证(agent-browser 未安装,装 Chromium 要 ~500MB 且规则禁止全局 npm),只做了 JS 语法校验 + 接口/导出功能验证 + +## 定开项目明细表改为按 Excel 表头顺序展示 +用户要求明细表字段参考导入的 Excel 表头。实际 Excel 表头顺序(14 列)为:项目归属 / 项目名称 / 项目ID / 下单时间 / 办事处 / 行业 / 市场下单人天 / 订单金额 / 市场责任人 / 项目状态 / 验收时间 / 推动验收计划 / 风险值 / 备注。 +- 重排 `renderCustomList` 表头+数据行,完全按此顺序;追加「更新时间」(用户此前要求,非 Excel 字段)与「操作」列 → 共 16 列。 +- 从主表移除 Excel 没有的旧列:区域、负责人(owner)、入库、交付、阶段、进度%、可确认/已计收/开票/回款 —— 这些仍保留在详情抽屉里。 +- 顶部统计行改为:合同额/万、人天、高风险数、已验收数(去掉空的计收/开票/回款)。 +- 纯前端改动(`web/app.js`),后端无需重启;接口此前已验证返回全部新字段。node `--check` 语法通过。 + +## 明细表交互改造:去按钮 / 状态筛选 / 新建表单对齐 Excel / 统计条 +用户四项要求:① 明细表去掉「详情」「归档」按钮 ② 新增「项目状态」筛选 ③「新建定开项目」字段参考 Excel 调整 ④ 表格上方加统计(项目数量+金额、本月/本年新增下单、本月/本年验收)。 + +### 新增字段 `accept_date_real`(验收日期,真实日期) +Excel 里「验收时间」是季度文本(Q1..Q4),无法算「本月/本年」,故新增 **`accept_date_real`**(String(16), YYYY-MM-DD, index)专门用于验收统计,与 `accept_date`(季度文本,展示用)并存互补。 +- 改动:`models.py`(字段 + to_dict)、`custom.py`(CustomProjectIn / PATCH 白名单 / create 时 `region` 可由 `office` 归一化推导)、`scripts/import_custom_ledger.py`(导入时对「已验收」+季度文本的行补当年季度末占位;**仅在库中为空时补,不覆盖用户手改的真日期**)。 +- 一次性回填:已导入的 119 行中 60 条「已验收」补上 2026 季度末(Q1→03-31/Q2→06-30/Q3→09-30/Q4→12-31),不动 `update_date`。结果:本年验收 60、本月(9月)验收 17。 + +### 前端(web/app.js + styles.css) +- **去按钮**:`renderCustomList` 行操作列只留「修改」(`data-cedit`);名称仍可点开详情(`data-copen`)。`bindCustomList` 移除 `data-carch` 绑定。 +- **状态筛选**:工具栏加 ``; } - /** 绑定页内搜索:防抖取数,重绘后把光标放回搜索框,连续打字不中断 */ - function bindKeyword(id) { + /** 绑定页内搜索。 + 默认:防抖实时搜索;opts.enter=true 时只在「回车 / 清空(×)」后才查询。 + 重绘后把光标放回搜索框,连续操作不中断。 */ + function bindKeyword(id, opts) { + opts = opts || {}; const el = $("#" + id); if (!el) return; - let t = null; - el.addEventListener("input", (e) => { - clearTimeout(t); - const v = e.target.value.trim(); - t = setTimeout(() => { - if (v === state.keyword) return; - keywordFocus = true; - state.keyword = v; - $("#globalSearch").value = v; - refresh(); - }, 320); - }); + const apply = (v) => { + v = (v || "").trim(); + if (v === state.keyword) return; + keywordFocus = true; + state.keyword = v; + $("#globalSearch").value = v; + refresh(); + }; + if (opts.enter) { + // 输入阶段不查询;回车提交后才检索 + el.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); apply(el.value); } + }); + // type=search 的清除「×」会触发 search 事件 + el.addEventListener("search", () => apply(el.value)); + } else { + let t = null; + el.addEventListener("input", (e) => { + clearTimeout(t); + const v = e.target.value.trim(); + t = setTimeout(() => apply(v), 320); + }); + } if (keywordFocus) { keywordFocus = false; el.focus(); @@ -183,7 +198,7 @@ ${ o.requireText ? `
-
` + ` : "" } @@ -579,7 +594,7 @@ } /* ---------------------------------------------------------------- 周看板 */ - /** 左栏一行:重点项目 / 定制项目共用 */ + /** 左栏一行:重点项目 / 定开项目共用 */ function weeklyItem(r) { const sel = state.weeklySel; const active = sel && sel.kind === r.kind && sel.id === r.id; @@ -690,7 +705,7 @@ ` : ""}`; } - /* 右栏:定制项目详情(交付环节 + 财务) */ + /* 右栏:定开项目详情(交付环节 + 财务) */ async function weeklyCustomDetail(row) { const d = await api("/custom/projects/" + row.id); const p = d.project; @@ -762,7 +777,7 @@ ); } - /** 定制项目本周动态里的一条:整行可点,点进去看该项目详情 */ + /** 定开项目本周动态里的一条:整行可点,点进去看该项目详情 */ function weeklyCustomRow(r, tag) { return `
@@ -781,7 +796,7 @@
`; } - /** 右栏:定制项目本周动态。左栏的「定制项目」只给入口,清单都收在这里 */ + /** 右栏:定开项目本周动态。左栏的「定开项目」只给入口,清单都收在这里 */ function weeklyCustomOverview(data) { const rows = data.custom; const added = rows.filter((r) => r.is_new); @@ -798,7 +813,7 @@ `; return `
-
${Ico.badge("package")}定制项目 · 本周动态
+
${Ico.badge("package")}定开项目 · 本周动态
新入库 ${added.length} 个 有交付动态 ${moved.length} 个 @@ -807,7 +822,7 @@
- +
${sec("本周新增", "入库时间落在本周期", added, @@ -817,7 +832,7 @@ ${state.weeklyAllCustom ? sec("其他在途项目", "本周没有动态", idle, (r) => (r.is_key ? `重点` : "")) : ""} ${!state.weeklyAllCustom && idle.length - ? `
另有 ${idle.length} 个在途定制项目本周没有动态,勾选「列入全部在途」一并列出。
` : ""}`; + ? `
另有 ${idle.length} 个在途定开项目本周没有动态,勾选「列入全部在途」一并列出。
` : ""}`; } function bindWeeklyCustomOverview(box) { @@ -899,7 +914,7 @@ - ${esc(scopeLabel())} · 项目 ${data.projects.length} 个 · 定制动态 ${data.kpi.custom_new + data.kpi.custom_updated} 个 + ${esc(scopeLabel())} · 项目 ${data.projects.length} 个 · 定开动态 ${data.kpi.custom_new + data.kpi.custom_updated} 个 `; const kpi = ` @@ -908,7 +923,7 @@
${Ico.badge("alert-circle")}
重点项目未更新
${k.key_idle}
每周都要更新一次
${Ico.badge("list-checks")}
本周重点工作
${focus.week.length}
延期 ${focus.overdue.length} 条 · 已完成 ${focus.done.length} 条
${Ico.badge("activity")}
执行记录
${k.update_count}
在办项目 ${k.project_listed} 个
-
${Ico.badge("wallet")}
定制合同额
${wan(k.custom_amount)}
${k.custom_total} 个项目 · 本期计收 ${fen(k.revenue_delta)} 万
+
${Ico.badge("wallet")}
定开合同额
${wan(k.custom_amount)}
${k.custom_total} 个项目 · 本期计收 ${fen(k.revenue_delta)} 万
${Ico.badge("alert")}
风险 / 阻塞
${k.risk_count}
本期上报条目
`; @@ -919,7 +934,7 @@ ${rows.length ? rows.map(weeklyItem).join("") : `
本周期没有记录
`} `; - // 左栏顺序:本周工作重点 → 重点项目 → 定制项目(只给入口,点开在右栏看清单) + // 左栏顺序:本周工作重点 → 重点项目 → 定开项目(只给入口,点开在右栏看清单) const focusList = focus.overdue.concat(focus.week); const cNew = data.custom.filter((r) => r.is_new); const cUpd = data.custom.filter((r) => r.has_update && !r.is_new); @@ -935,12 +950,12 @@ ${group("重点项目", data.projects, "个已更新")}
-
定制项目${data.custom.length} +
定开项目${data.custom.length} 本周动态 ${cNew.length + cUpd.length} 个
${Ico.svg("package")} - 本周定制项目动态 + 本周定开项目动态 ${Ico.svg("arrow-right")}
@@ -1378,7 +1393,7 @@ host.innerHTML = toolbar + body; - bindKeyword("pKeyword"); + bindKeyword("pKeyword", { enter: true }); host.querySelectorAll("[data-pidle]").forEach((b) => b.addEventListener("click", () => { state.projIdleOnly = !state.projIdleOnly; renderProjects(); }) ); @@ -1443,7 +1458,7 @@ }); } - /* ---------------------------------------------------------------- 定制交付 */ + /* ---------------------------------------------------------------- 定开项目 */ function stageMeta(code) { const list = (state.customMeta && state.customMeta.stages) || []; return list.find((s) => s.code === code) || { code, name: code, color: "#94a3b8" }; @@ -1453,43 +1468,50 @@ return `${Ico.stage(code)}${esc(s.name)}`; } - /** 定制交付视图的查询条件:周期 + 项目名称检索 + 区域(+ 归档态) */ - function customQuery(extra) { - const p = []; - if (state.gran === "week") { - if (state.weekId) p.push("period_id=" + state.weekId); - } else { - const m = currentMonth(); - if (m) p.push("year=" + m.year, "month=" + m.month); - } - if (state.keyword) p.push("keyword=" + encodeURIComponent(state.keyword)); - if (state.customRegion) p.push("region=" + encodeURIComponent(state.customRegion)); - (extra || []).forEach((x) => p.push(x)); - return "?" + p.join("&"); + /* ---- 市场台账(《定开项目.xlsx》)的小色块 ---- */ + const CUSTOM_STATUS_COLOR = { + 已验收: "#16a34a", + 交付完成: "#0891b2", + 交付中: "#2563eb", + 待开发: "#b45309", + 退单: "#7a8394", + }; + + /** 项目状态:已验收/交付完成/交付中/待开发/退单 */ + function statusPill(code) { + if (!code) return `—`; + const color = CUSTOM_STATUS_COLOR[code] || "#7a8394"; + return `${esc(code)}`; + } + + /** 风险值:1.高风险 红 / 2.低风险 绿 */ + function riskPill(code) { + if (!code) return `—`; + const high = String(code).includes("高"); + const color = high ? "#dc2626" : "#16a34a"; + const text = String(code).replace(/^\d+\./, ""); + return `${esc(text)}`; } let keywordFocus = false; + /** 定开「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */ + const CUSTOM_ORIGINS = ["新签订单", "存量项目", "汇智直签", "提前交付", "其他"]; + const CUSTOM_PLACED = ["新签订单", "存量项目", "汇智直签"]; + const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0; + /** 年度计收目标(万元),来源《年度任务.xlsx》(Q1 35 / Q2 225 / Q3 160 / Q4 260,合计 680) */ + const CUSTOM_ANNUAL_TARGETS = { 1: 35, 2: 225, 3: 160, 4: 260 }; + async function renderCustom() { saveUI(); const host = $("#view-custom"); host.innerHTML = `
加载中…
`; if (!state.customMeta) state.customMeta = await api("/custom/meta"); - const [board, stats] = await Promise.all([ - api("/custom/board" + customQuery()), - api("/custom/stats" + customQuery(["archived=" + state.customArchived])), - ]); - state.customBoard = board; - state.customStats = stats; - const k = stats.kpi; - - // 项目明细 / 交付流水线 / 财务跟踪 都基于 /custom/projects: - // 支持归档态筛选,并按入库时间倒序 - const needRows = ["list", "pipeline", "finance"].indexOf(state.customTab) >= 0; - state.customRows = needRows - ? await api("/custom/projects" + customQuery(["archived=" + state.customArchived])) - : []; + // 一次性取「全部定开项目」(不带搜索/筛选条件): + // 列表在本地按关键字/区域/状态筛选,而统计条始终基于全部数据。 + const allRows = await api("/custom/projects?archived=" + state.customArchived); + state.customRows = allRows; // 区域筛选:用规范名称、按大区分组,只列出实际有项目的区域 const usedSet = {}; @@ -1510,392 +1532,376 @@ ``; } - const tabs = ` + const STATUS_OPTIONS = ["已验收", "待开发", "交付完成", "交付中", "退单"]; + const statusFilterHtml = `` + + STATUS_OPTIONS.map((s) => ``).join(""); + const originFilterHtml = `` + + CUSTOM_ORIGINS.map((o) => ``).join(""); + + const isList = state.customTab !== "weekly" && state.customTab !== "annual"; + const toolbar = `
- - - - + + + - ${keywordInputHtml("cKeyword", "搜索项目名称…", 200)} - - - - ${esc(scopeLabel())} · ${k.project_count} 个项目 + ${isList ? ` + ${keywordInputHtml("cKeyword", "搜索项目名称…(回车查询)", 200)} + + + + + + 全部 ${allRows.length} 个项目` + : state.customTab === "annual" + ? `计收按「验收时间」、签单按「下单时间」归属季度` + : `${esc(scopeLabel())} · 共 ${allRows.length} 个项目`}
`; - let body = ""; - if (state.customTab === "pipeline") body = renderCustomPipeline(state.customRows, k); - else if (state.customTab === "board") body = renderCustomBoard(board); - else if (state.customTab === "finance") body = renderCustomFinance(stats, state.customRows); - else body = renderCustomList(state.customRows); - - host.innerHTML = tabs + body; + host.innerHTML = toolbar + ( + state.customTab === "weekly" ? renderCustomWeekly(allRows) + : state.customTab === "annual" ? renderCustomAnnual(allRows) + : renderCustomList(allRows)); host.querySelectorAll("[data-ctab]").forEach((b) => b.addEventListener("click", () => { state.customTab = b.dataset.ctab; renderCustom(); }) ); + const os = $("#cOrigin"); + if (os) os.addEventListener("change", () => { state.customOrigin = os.value; renderCustom(); }); const rs = $("#cRegion"); if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); }); - bindKeyword("cKeyword"); + const st = $("#cStatus"); + if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); }); + if (isList) bindKeyword("cKeyword", { enter: true }); const bn = $("#btnNewCustom"); if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite())); const bex = $("#btnExportCustom"); if (bex) bex.addEventListener("click", () => { + // 导出与「项目明细」当前筛选一致:归档态 + 归属 + 区域 + 状态 + 关键字 const q = ["archived=" + state.customArchived]; + if (state.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin)); if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion)); + if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus)); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); - download("/api/export/custom" + (q.length ? "?" + q.join("&") : "")); + download("/api/export/custom?" + q.join("&")); }); - // 卡片只是入口:重点标记等基础信息一律进详情面板改,外层不再直接写数据 - host.querySelectorAll("[data-cpid]").forEach((c) => - c.addEventListener("click", () => openCustomDrawer(Number(c.dataset.cpid))) - ); bindCustomList(host); } - function renderCustomBoard(board) { - const cols = board.columns.filter((c) => c.count > 0 || c.code !== "closed"); - return `
- ${cols.map((c) => ` -
-
-
${Ico.stage(c.code)}${esc(c.name)}
-
${c.count} 个 · ${fen(c.amount)} 万${c.updated_count ? ` · 本期 ${c.updated_count}` : ""}
-
-
- ${c.projects.length ? c.projects.map((p) => ` -
-
-
${esc(p.name)}
- ${starMark(p.is_key, true, "重点标记在详情面板中维护")} -
-
- ${esc(p.region || "未标注")} - ${p.amount ? fen(p.amount) + " 万" : "金额待定"} -
- ${p.stage === "developing" ? `
-
开发进度${p.progress}%
` : ""} -
- 可确认 ${fen(p.confirmed_amount)} 万 - 已计收 ${fen(p.revenue_amount || 0)} 万 -
- ${p.remark ? `
${esc(p.remark.slice(0, 60))}
` : ""} -
`).join("") : `
—
`} -
-
`).join("")} -
`; - } + function renderCustomList(allRows) { + // 列表:按 关键字 + 归属 + 区域 + 状态 在本地筛选;并按「更新时间」倒序 + const kw = (state.keyword || "").trim(); + let list = allRows; + if (kw) list = list.filter((p) => String(p.name || "").includes(kw)); + if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin); + if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion); + if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus); + const view = list.slice().sort((a, b) => + String(b.update_date || "").localeCompare(String(a.update_date || "")) + ); - /* 商务阶段 -> 已走完的环节下标。导入的老数据只有阶段没有环节日期,用它兜底 */ - const STAGE_STEP = { lead: 0, signed: 1, developing: 1, delivered: 2, accepted: 3, revenue: 4 }; + // ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ---- + const placed = allRows.filter(isPlaced); // 已下单 = 新签订单 / 存量项目 / 汇智直签 + const today = new Date(); + const Y = today.getFullYear(); + const M = today.getMonth() + 1; + const Q = Math.floor((M - 1) / 3) + 1; // 当前季度 1~4 + const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0); + // 取日期里的「年-月」;像「2024年3月前」这种文本不参与季度/年度判定 + const ymOf = (d) => { + const s = String(d || ""); + return /^\d{4}-\d{2}/.test(s) ? { y: Number(s.slice(0, 4)), m: Number(s.slice(5, 7)) } : null; + }; + const inYear = (d) => { const t = ymOf(d); return !!t && t.y === Y; }; + const inQuarter = (d) => { const t = ymOf(d); return !!t && t.y === Y && Math.floor((t.m - 1) / 3) + 1 === Q; }; - /** 按已填的环节日期取最后一环(与后端 custom_current_step 口径一致) */ - function customDateIndex(p) { - const steps = (state.customMeta && state.customMeta.pipeline) || []; - let step = -1; - steps.forEach((s, i) => { if ((p[s.date_field] || "").trim()) step = i; }); - return step; - } + const signedQuarter = placed.filter((p) => inQuarter(p.sign_date)); + const signedYear = placed.filter((p) => inYear(p.sign_date)); + // 验收:以「验收时间」(accept_date,季度文本 Q1~Q4) 为准;本季度 = 当前季度 + const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; }; + const thisQ = "Q" + Q; + const accRows = allRows.filter((p) => qOf(p.accept_date)); // 有「验收时间」即视为已验收 + const accQuarter = accRows.filter((p) => qOf(p.accept_date) === thisQ); + const accYear = accRows; - /** 项目当前走到哪一环:环节日期与商务阶段取更靠后的那个 */ - function customStepIndex(p) { - const byStage = STAGE_STEP[p.stage]; - return Math.max(byStage === undefined ? -1 : byStage, customDateIndex(p)); - } + const stat = (label, count, amount, color) => + `
${count}个 · ${fen(amount)}万
${label}
`; - function daysSince(iso) { - if (!iso) return null; - const t = Date.parse(iso + "T00:00:00"); - return isNaN(t) ? null : Math.floor((Date.now() - t) / 86400000); - } + const statBar = `
+ ${stat("已下单项目 / 合同额", placed.length, sum(placed, "amount"), "var(--primary)")} + ${stat(`本季度下单(${Y}年Q${Q})`, signedQuarter.length, sum(signedQuarter, "amount"), "var(--violet)")} + ${stat(`本年新增下单(${Y}年)`, signedYear.length, sum(signedYear, "amount"), "var(--violet)")} + ${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")} + ${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")} +
+
「已下单项目 / 合同额」与「本季度·本年下单」只统计 新签订单 / 存量项目 / 汇智直签(提前交付不计入下单);统计基于全部项目,不受搜索 / 筛选影响。
`; - /** 交付流水线:入库 → 签单 → 交付 → 验收 → 计收 的漏斗与卡点 */ - function renderCustomPipeline(rows, kpi) { - const steps = (state.customMeta && state.customMeta.pipeline) || []; - const agingDays = (state.customMeta && state.customMeta.aging_days) || 45; - const total = rows.length; + const manDays = view.reduce((a, p) => a + Number(p.man_days || 0), 0); + const highRisk = view.filter((p) => String(p.risk_level || "").includes("高")).length; + const accepted = view.filter((p) => p.project_status === "已验收").length; - const funnel = steps.map((s, i) => { - const hit = rows.filter((p) => customStepIndex(p) >= i); - return { - key: s.code, name: s.name, color: s.color, - count: hit.length, - amount: hit.reduce((a, p) => a + (p.amount || 0), 0), - }; - }); - const maxCount = Math.max(1, ...funnel.map((f) => f.count)); - - // 卡住的项目:还没走到计收,且最后一个有日期的环节停留超过 agingDays 天 - const stuck = []; - rows.forEach((p) => { - const i = customStepIndex(p); - if (i < 0 || i >= steps.length - 1) return; - const di = customDateIndex(p); - if (di < 0) return; - const days = daysSince(p[steps[di].date_field]); - if (days === null || days < agingDays) return; - stuck.push({ p: p, step: steps[i], from: p[steps[di].date_field], days: days }); - }); - stuck.sort((a, b) => b.days - a.days); - const noDate = rows.filter((p) => customDateIndex(p) < 0).length; - - return ` -
-

交付漏斗在库 ${total} 个 · 重点 ${kpi.key_count} 个 · 本期更新 ${kpi.period_updated} 个

- 金额与项目数按环节累计 · 商务阶段与已录环节日期取先到哪一步
-
-
- ${funnel.map((f) => ` -
-
${Ico.pipe(f.key)}${esc(f.name)}
-
${f.count}
-
-
${fen(f.amount)} 万
-
`).join("")} -
- ${noDate ? `
还有 ${noDate} 个项目一个环节日期都没有,去「项目明细」补上入库时间后漏斗才准。
` : ""} -
-
- -
-

卡点提醒最后一个有日期的环节停留超过 ${agingDays} 天

- ${stuck.length} 个项目需要推进
- ${stuck.length ? `
- - - ${stuck.slice(0, 20).map((x) => ` - - - - - - - - - `).join("")} -
项目名称卡在环节日期停留天数合同额负责人操作
${esc(x.p.name)}${esc(x.step.name)}${esc(x.p[x.step.date_field] || x.from || "—")}${x.days} 天${fen(x.p.amount)} 万${esc(x.p.owner || "未指派")}
` : `
没有停滞超期的项目
`} -
`; - } - - /** 财务跟踪:按负责人 / 区域汇总,并列出待回款排行 */ - function renderCustomFinance(stats, rows) { - const byOwner = {}; - rows.forEach((p) => { - const key = p.owner || "(未指派)"; - const o = byOwner[key] || (byOwner[key] = { name: key, count: 0, amount: 0, confirmed: 0, revenue: 0, invoice: 0, received: 0 }); - o.count += 1; - o.amount += p.amount || 0; - o.confirmed += p.confirmed_amount || 0; - o.revenue += p.revenue_amount || 0; - o.invoice += p.invoice_amount || 0; - o.received += p.received_amount || 0; - }); - const owners = Object.keys(byOwner).map((k) => byOwner[k]).sort((a, b) => b.amount - a.amount); - const unpaid = rows.filter((p) => (p.amount || 0) > 0) - .map((p) => ({ p: p, pend: (p.amount || 0) - (p.received_amount || 0) })) - .sort((a, b) => b.pend - a.pend); - - const num = (n) => fen(n); - const maxAmt = Math.max(1, ...stats.by_stage.map((x) => x.amount || 0)); - const k = stats.kpi; - return ` -
-
${Ico.badge("wallet")}
合同总额
${wan(k.amount_total)}
${k.project_count} 个项目 · 重点 ${k.key_count} 个
-
${Ico.badge("receipt")}
按进度可确认收入
${wan(k.confirmed_total)}
确认比例 ${k.progress_rate}%
-
${Ico.badge("wallet")}
已计收
${wan(k.revenue_total)}
计收率 ${k.revenue_rate}%
-
${Ico.badge("hourglass")}
待计收
${wan(k.pending_total)}
可确认 − 已计收
-
${Ico.badge("receipt")}
已开票
${wan(k.invoice_total)}
开票率 ${k.amount_total ? ((k.invoice_total / k.amount_total) * 100).toFixed(1) : 0}%
-
${Ico.badge("trending-up")}
已回款
${wan(k.received_total)}
回款率 ${k.received_rate}%
-
${Ico.badge("receipt")}
待开票
${wan(k.pending_invoice)}
合同额 − 已开票
-
${Ico.badge("hourglass")}
待回款
${wan(k.pending_received)}
合同额 − 已回款
-
-
-
-

阶段金额分布合同额与按进度可确认收入

-
- ${stats.by_stage.map((x) => ` -
-
${esc(x.name)} ${x.count} 个
-
-
${num(x.amount)}
-
`).join("")} -
-
-
-

周期记录活跃度各周期有记录的项目数

-
- ${barChart(stats.trend.map((t) => ({ - label: `${t.month}/${t.week || ""}`, - value: t.update_count, - color: scopePeriodIds().indexOf(t.period_id) >= 0 ? "#7c3aed" : "#c4b5fd", - dim: scopePeriodIds().indexOf(t.period_id) < 0, - })), { unit: " 条", height: 170 })} -
-
-
- -
-
-

按负责人合同额 / 计收 / 开票 / 回款

-
- - - - ${owners.map((o) => ` - - - - `).join("")} - -
负责人项目合同额可确认已计收已开票已回款
${esc(o.name)}${o.count}${num(o.amount)}${num(o.confirmed)}${num(o.revenue)}${num(o.invoice)}${num(o.received)}
-
-
-
-

按区域合同额与可确认收入

-
- - - - ${stats.by_region.slice(0, 14).map((r) => ` - - - `).join("")} - -
区域项目合同额可确认已计收
${esc(r.name)}${r.count}${num(r.amount)}${num(r.confirmed)}${num(r.revenue)}
-
-
-
- -
-

待回款排行合同额 − 已回款 · 共 ${unpaid.length} 个有合同额的项目

- ${unpaid.length ? `
- - - - ${unpaid.slice(0, 20).map((x) => ` - - - - - - - - - - - `).join("")} -
项目名称区域负责人验收合同额开票回款待回款操作
${esc(x.p.name)}${esc(x.p.region || "—")}${esc(x.p.owner || "未指派")}${esc(x.p.accept_date || "未验收")}${num(x.p.amount)}${num(x.p.invoice_amount)}${num(x.p.received_amount)}${num(x.pend)}
` : `
暂无合同额数据
`} -
`; - } - - function renderCustomList(rows) { - const archOpts = [ - ["false", "在库"], - ["true", "已归档"], - ["all", "全部"], - ] - .map((x) => ``) - .join(""); - - const sum = (f) => fen(rows.reduce((a, p) => a + Number(p[f] || 0), 0)); return `
-

定制项目明细按入库时间倒序 · 共 ${rows.length} 个 · 列表只读,改数据请点「修改」

- 合同额 ${sum("amount")} 万 · 可确认 ${sum("confirmed_amount")} 万 · 已计收 ${sum("revenue_amount")} 万 · 开票 ${sum("invoice_amount")} 万 · 回款 ${sum("received_amount")} 万 +

定开项目总览按「更新时间」倒序 · 共 ${view.length} 个${state.customStatus ? "(" + esc(state.customStatus) + ")" : ""} · 列表只读,改数据请点「修改」

+ 人天 ${manDays.toLocaleString()} · 高风险 ${highRisk} 个 · 已验收 ${accepted} 个
- +
- ${rows.length ? `
- - - - - - - - - - - ${rows.map((p) => ` - - - - - ${["entry_date", "sign_date", "deliver_date", "accept_date"].map((f) => ``).join("")} - - - - - - - - - - `).join("")} - -
项目名称区域负责人入库签单交付验收阶段合同额/万进度%可确认/万已计收/万开票/万回款/万备注操作
-
- ${starMark(p.is_key, true, "重点标记在详情面板中维护")} - ${esc(p.name)} - ${p.archived ? `已归档` : ""} -
-
${esc(p.region || "—")}${esc(p.owner || "—")}${p[f] ? esc(p[f]) : "—"}${stagePill(p.stage)}${fen(p.amount)}${Math.round(p.progress)}%${fen(p.confirmed_amount)}${fen(p.revenue_amount)}${fen(p.invoice_amount)}${fen(p.received_amount)}${esc((p.remark || "").slice(0, 40)) || "—"} - - - -
-
` : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定制项目" : "当前筛选条件下没有定制项目"}
`} + ${statBar} + ${view.length ? customTableHtml(view) : `
${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}
`}
`; } - /** 项目明细表:列表只读,只有详情 / 修改 / 归档三个动作 */ - function bindCustomList(host) { - const ca = $("#cArchived"); - if (ca) ca.addEventListener("change", () => { state.customArchived = ca.value; renderCustom(); }); + /** 定开项目表格(「项目明细」与「本周进展」两个 tab 共用) */ + function customTableHtml(view, withOps) { + const ops = withOps !== false; + return `
+ + + + + + + + + + + + + + + + + + + ${ops ? `` : ""} + + + ${view.map((p) => ` + + + + + + + + + + + + + + + + + + ${ops ? `` : ""} + `).join("")} + +
项目名称项目ID下单时间项目归属办事处行业市场下单人天订单金额市场责任人项目状态进度验收时间推动验收计划风险值备注更新时间操作
+
+ ${starMark(p.is_key, true, "重点标记在详情面板中维护")} + ${esc(p.name)} + ${p.archived ? `已归档` : ""} +
+
${esc(p.project_code || "—")}${p.sign_date ? esc(p.sign_date) : "—"}${esc(p.origin || "—")}${esc(p.office || "—")}${esc(p.industry || "—")}${p.man_days == null ? "—" : Math.round(p.man_days).toLocaleString()}${money(p.amount)}${esc(p.market_owner || "—")}${statusPill(p.project_status)}${(p.project_status === "交付中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"}${p.accept_date ? esc(p.accept_date) : "—"}${esc(p.accept_plan || "—")}${riskPill(p.risk_level)}${esc((p.remark || "").slice(0, 40)) || "—"}${esc(p.update_date || "—")}
+
`; + } + /** 本周进展:更新时间落在本周(周一~周日)的项目;并按 验收/下单/提前交付/进行中 分类统计 */ + function renderCustomWeekly(allRows) { + const d = new Date(); + const dow = (d.getDay() + 6) % 7; // 周一 = 0 + const mon = new Date(d.getFullYear(), d.getMonth(), d.getDate() - dow); + const sun = new Date(mon.getFullYear(), mon.getMonth(), mon.getDate() + 6); + const iso = (x) => x.getFullYear() + "-" + String(x.getMonth() + 1).padStart(2, "0") + "-" + String(x.getDate()).padStart(2, "0"); + const start = iso(mon), end = iso(sun); + const dayOf = (v) => String(v || "").slice(0, 10); + const inWeek = (v) => { const s = dayOf(v); return !!s && s >= start && s <= end; }; + const week = allRows.filter((p) => inWeek(p.update_date)); // 更新时间在本周 + + const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0); + const acc = week.filter((p) => p.project_status === "已验收"); + // 本周新增项目:按「下单时间」在本周的新下单 / 提前交付项目(不是「本周更新」) + const newDeal = allRows.filter((p) => inWeek(p.sign_date) && (isPlaced(p) || p.origin === "提前交付")); + // 交付项目:本周更新的「交付中 + 交付完成」 + const doing = week.filter((p) => p.project_status === "交付中" || p.project_status === "交付完成"); + + const stat = (label, rows, color) => + `
${rows.length}个 · ${fen(sum(rows, "amount"))}万
${label}
`; + + const statBar = `
+ ${stat("验收情况", acc, "var(--amber)")} + ${stat("本周新增项目", newDeal, "var(--violet)")} + ${stat("交付项目", doing, "var(--primary)")} +
+
本周 = ${start} ~ ${end};本周新增项目 = 按「下单时间」在本周的新下单 / 提前交付项目(已下单 + 提前交付);验收情况 / 交付项目 为「更新时间」在本周的项目(交付项目 = 交付中 + 交付完成)。
`; + + return `
+
+

本周进展更新时间在本周(${start} ~ ${end})· 共 ${week.length} 个项目 · 列表只读,改数据请点项目名称

+ 验收 ${acc.length} · 新增 ${newDeal.length} · 交付 ${doing.length} +
+ ${statBar} + ${week.length + ? customTableHtml(week.slice().sort((a, b) => String(b.update_date || "").localeCompare(String(a.update_date || ""))), false) + : `
本周(${start} ~ ${end})没有更新的定开项目
`} +
`; + } + + /** 年度任务:季度计收目标(《年度任务.xlsx》)+ 按「验收时间 / 下单时间」回填的完成数据 */ + function renderCustomAnnual(allRows) { + const Y = new Date().getFullYear(); + const qOf = (s) => { const t = String(s || "").trim().toUpperCase(); return /^Q[1-4]$/.test(t) ? t : ""; }; + const signQ = (d) => { + const s = String(d || ""); + if (!/^\d{4}-\d{2}/.test(s) || Number(s.slice(0, 4)) !== Y) return 0; + return Math.floor((Number(s.slice(5, 7)) - 1) / 3) + 1; + }; + const sumW = (arr) => arr.reduce((a, p) => a + Number(p.amount || 0), 0) / 10000; // 元 → 万元 + const fmt = (v) => v.toLocaleString("zh-CN", { maximumFractionDigits: 2 }); + + const rows = [1, 2, 3, 4].map((q) => { + const acc = allRows.filter((p) => qOf(p.accept_date) === "Q" + q); // 计收:验收项目 + const sign = allRows.filter((p) => isPlaced(p) && signQ(p.sign_date) === q); // 签单:下单项目(已下单) + const target = CUSTOM_ANNUAL_TARGETS[q] || 0; + const accAmt = sumW(acc); + return { q, target, accN: acc.length, accAmt, rate: target ? (accAmt / target) * 100 : null, signN: sign.length, signAmt: sumW(sign) }; + }); + const tot = rows.reduce((a, r) => ({ + target: a.target + r.target, accN: a.accN + r.accN, accAmt: a.accAmt + r.accAmt, + signN: a.signN + r.signN, signAmt: a.signAmt + r.signAmt, + }), { target: 0, accN: 0, accAmt: 0, signN: 0, signAmt: 0 }); + tot.rate = tot.target ? (tot.accAmt / tot.target) * 100 : null; + + const tr = (label, r, strong) => ` + + ${label} + ${fmt(r.target)} + ${r.accN} + ${fmt(r.accAmt)} + ${r.rate == null ? "—" : r.rate.toFixed(1) + "%"} + ${r.signN} + ${fmt(r.signAmt)} + `; + + return `
+
+

年度任务(${Y}年)计收目标取自《年度任务.xlsx》;完成数据按时间自动回填

+ 已验收 ${tot.accN} 个 · 新增签单 ${tot.signN} 个 +
+
+ + + + + + + + + + + + ${rows.map((r) => tr("Q" + r.q, r, false)).join("")} + ${tr("合计", tot, true)} + +
季度计收目标(万元)计收项目数量计收项目金额(万元)计收完成率新增签单项目数量新增签单项目数量金额(万元)
+
+
计收项目 = 按「验收时间」归属季度的项目;新增签单 = 按「下单时间」归属本年度季度、且项目归属为已下单(新签订单 / 存量项目 / 汇智直签)的项目;金额由「元」换算为「万元」;计收完成率 = 计收项目金额 ÷ 计收目标。
+
`; + } + + /** 项目明细表:列表只读,行内只有「修改」;名称可点开详情 */ + function bindCustomList(host) { host.querySelectorAll("[data-copen]").forEach((a) => a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view")) ); host.querySelectorAll("[data-cedit]").forEach((b) => b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit")) ); - host.querySelectorAll("[data-carch]").forEach((b) => - b.addEventListener("click", () => { - const p = (state.customRows || []).find((x) => x.id === Number(b.dataset.carch)); - if (p) archiveCustomProject(p, () => afterWrite()); - }) - ); + const bp = $("#btnPurgeWeek"); + if (bp) bp.addEventListener("click", purgeWeek); + } + + /** 删除本周(周一~周日)更新的定开项目:先预览数量,二次确认后执行。 */ + async function purgeWeek() { + let pv; + try { + pv = await api("/custom/weekly-preview"); + } catch (e) { + return toast("读取本周更新失败:" + e.message); + } + if (!pv.count) { + return toast("本周(" + pv.start + " ~ " + pv.end + ")没有更新的定开项目"); + } + const ok = await confirmDialog({ + title: "删除本周更新的定开项目", + okText: "确认删除 " + pv.count + " 个", + danger: true, + requireText: "删除", + requirePlaceholder: "输入「删除」以确认", + body: + `

将删除更新时间在本周(${esc(pv.start)} ~ ${esc(pv.end)})内的定开项目:` + + `${pv.count} 个,合同额合计 ${fen(pv.amount)} 万。

` + + `
此操作会连同项目的更新记录一起删除,且不可撤销。` + + `若只是想标记,请在详情里改状态。
` + + (pv.names && pv.names.length + ? `
${pv.names.map((n) => esc(n)).join("、")}${pv.count > pv.names.length ? " …" : ""}
` + : ""), + }); + if (!ok) return; + try { + const r = await post("/custom/purge-week", {}); + toast("已删除本周更新的 " + r.deleted + " 个定开项目"); + afterWrite(); + } catch (e) { + toast("删除失败:" + e.message); + } } /** - * 新建定制项目 —— 全站唯一的新建入口。 - * 「定制交付」工具栏和「更新录入」页都调用它,避免两套录入逻辑。 + * 新建定开项目 —— 全站唯一的新建入口。 + * 「定开项目」工具栏和「更新录入」页都调用它,避免两套录入逻辑。 */ function openNewCustomDialog(onDone) { const today = new Date(); const iso = today.getFullYear() + "-" + String(today.getMonth() + 1).padStart(2, "0") + "-" + String(today.getDate()).padStart(2, "0"); - const stageOpts = ((state.customMeta && state.customMeta.stages) || []) - .map((s) => ``) - .join(""); + + // 选项直接对齐《定开项目.xlsx》台账字段取值 + const opts = (list, sel) => + list.map((v) => ``).join(""); + const originOpts = opts(CUSTOM_ORIGINS, "新签订单"); + const industryOpts = opts(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], ""); + const statusOpts = opts(["待开发", "交付中", "交付完成", "已验收", "退单"], "待开发"); + const riskOpts = opts(["1.高风险", "2.低风险"], "2.低风险"); + const numVal = (sel) => ($(sel).value === "" ? null : Number($(sel).value)); + const txtVal = (sel) => $(sel).value.trim() || null; openModal( - ` + ` @@ -1906,21 +1912,29 @@ ); const submit = async () => { - const name = $("#ncName").value.trim(); + const name = txtVal("#ncName"); if (!name) return modalError("请填写项目名称"); const btn = $("#mOk"); btn.disabled = true; try { const p = await post("/custom/projects", { name: name, - region: $("#ncRegion").value || null, - customer: $("#ncCustomer").value.trim() || null, - amount: $("#ncAmount").value === "" ? null : Number($("#ncAmount").value), - stage: $("#ncStage").value, - owner: $("#ncOwner").value || null, - entry_date: $("#ncEntry").value || null, + origin: txtVal("#ncOrigin"), + project_code: txtVal("#ncCode"), + sign_date: txtVal("#ncSign"), + office: txtVal("#ncOffice"), + industry: txtVal("#ncIndustry"), + man_days: numVal("#ncManDays"), + progress: numVal("#ncProgress") == null ? 0 : numVal("#ncProgress"), + amount: numVal("#ncAmount"), + market_owner: txtVal("#ncMarketOwner"), + project_status: txtVal("#ncStatus"), + accept_date: txtVal("#ncAcceptQuarter"), + accept_plan: txtVal("#ncAcceptPlan"), + risk_level: txtVal("#ncRisk"), + accept_date_real: txtVal("#ncAcceptReal"), is_key: $("#ncKey").checked, - remark: $("#ncRemark").value.trim() || null, + remark: txtVal("#ncRemark"), }); closeModal(); toast("已创建「" + p.name + "」"); @@ -1938,14 +1952,14 @@ async function archiveCustomProject(p, onDone) { const toArchive = !p.archived; const ok = await confirmDialog({ - title: toArchive ? "归档定制项目" : "恢复定制项目", + title: toArchive ? "归档定开项目" : "恢复定开项目", okText: toArchive ? "确认归档" : "确认恢复", body: `

${esc(p.name)}

` + `
${ toArchive - ? "归档后该项目不再出现在阶段看板、收入统计和默认列表中,历史周期记录完整保留。" - : "恢复后重新计入看板与统计。" + ? "归档后该项目不再出现在项目明细与统计中,历史周期记录完整保留。" + : "恢复后重新计入项目明细与统计。" }
`, }); if (!ok) return; @@ -1961,7 +1975,7 @@ /** 删除:连带周期记录一起清除,需逐字输入项目名确认 */ async function deleteCustomProject(p, onDone) { const ok = await confirmDialog({ - title: "删除定制项目", + title: "删除定开项目", danger: true, okText: "永久删除", requireText: p.name, @@ -1983,7 +1997,7 @@ } } - /** cid 定制项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */ + /** cid 定开项目抽屉;mode = "view" 只读详情(默认) / "edit" 编辑基础信息 */ async function openCustomDrawer(cid, mode) { mode = mode === "edit" ? "edit" : "view"; $("#drawerMask").classList.add("open"); @@ -1996,10 +2010,6 @@ $("#drawerTitle").innerHTML = `${p.is_key ? starMark(true) : ""}${esc(p.name)}`; $("#drawerSub").innerHTML = `${stagePill(p.stage)} · ${esc(p.region || "未标注区域")} · 合同额 ${money(p.amount)}`; - const stageOpts = state.customMeta.stages - .map((s) => ``) - .join(""); - const timeline = d.timeline.slice().reverse().map((t) => `
${esc(t.period.full_label.replace(/^\d+年/, ""))}
@@ -2017,35 +2027,54 @@ : `按阶段 ${p.revenue_ratio}%`; const ro = (label, val) => `
${val}
`; + const optList = (list, sel) => list.map((v) => ``).join(""); + const originOpts = optList(CUSTOM_ORIGINS, p.origin); + const industryOpts = optList(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], p.industry); + const statusOpts = optList(["待开发", "交付中", "交付完成", "已验收", "退单"], p.project_status); + const riskOpts = optList(["1.高风险", "2.低风险"], p.risk_level); - // 基础信息:详情只读、修改才给表单,操作条只出现在自己那一档里 - const baseSec = mode === "edit" ? ` + // 市场台账(来源《定开项目.xlsx》):详情只读,修改模式给输入框 + const numOrDash = (v) => (v === null || v === undefined ? "—" : String(Math.round(v))); + const ledgerSec = `
-
基础信息编辑
-
只改基础信息与财务数字,不会动下面的交付记录
-
-
-
-
%
-
-
-
- % - 当前 ${ratioLabel} -
-
-
-
- ${pipelineBar(p)} -
入库时间默认取该项目最早记录所在周期的第一天(周一),可手工修正;补上后续环节日期会自动推进阶段。
-
-
-
-
${ownerSelectHtml(p.owner, 'id="cdOwner" style="width:150px"')}
- ${peopleEditorHtml("cdMembers", "团队成员", p.members, { add: "成员" })} -
-
-
` : ` +
定开项目信息${mode === "edit" ? "编辑" : "只读"}
+ ${mode === "edit" ? ` +
+
+
+
+
+
+
+
+
+
+
%
+
+
+
+
+
+
${esc(p.update_date || "—")}跟随「下单时间」
+ ` : ` + ${ro("项目归属", esc(p.origin || "—"))} + ${ro("项目ID", esc(p.project_code || "—"))} + ${ro("办事处", esc(p.office || "—"))} + ${ro("行业", esc(p.industry || "—"))} + ${ro("下单时间", esc(p.sign_date || "—"))} + ${ro("市场下单人天", numOrDash(p.man_days))} + ${ro("市场责任人", esc(p.market_owner || "—"))} + ${ro("项目状态", statusPill(p.project_status))} + ${ro("进度", (p.project_status === "交付中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—")} + ${ro("验收时间", esc(p.accept_date || "—"))} + ${ro("推动验收计划", esc(p.accept_plan || "—"))} + ${ro("风险值", riskPill(p.risk_level))} + ${ro("更新时间", esc(p.update_date || "—"))} + `} +
`; + + // 基础信息:只读展示(编辑请用上方「定开项目信息」表单) + const baseSec = `
基础信息只读
${ro("项目名称", esc(p.name) + (p.archived ? ` 已归档` : ""))} @@ -2071,10 +2100,11 @@ $("#drawerBody").innerHTML = ` ${mode === "edit" ? `` : ""} + ${ledgerSec} + ${mode === "view" ? ` ${baseSec} -
-
收入确认${mode === "edit" ? "开票与回款可编辑" : "计算值 · 只读"}
+
收入确认计算值 · 只读
${Ico.badge("wallet")}
合同额
${wan(p.amount)}
${Ico.badge("receipt")}
可确认收入
${wan(p.confirmed_amount)}
比例 ${p.revenue_ratio}%
@@ -2085,12 +2115,6 @@
${Ico.badge("receipt")}
已开票
${wan(p.invoice_amount)}
待开票 ${wan(p.pending_invoice)}
${Ico.badge("trending-up")}
已回款
${wan(p.received_amount)}
待回款 ${wan(p.pending_received)}
- ${mode === "edit" ? `
- 开票与回款 · 编辑 - - - 随标题栏「保存」一并提交 -
` : ""}
@@ -2098,7 +2122,7 @@
附表 · 更新记录 - 到「更新录入 · 定制交付」写入 + 到「更新录入 · 定开项目」写入
${d.updates.length ? d.updates.map((u) => `
@@ -2109,7 +2133,8 @@
`).join("") : `
暂无记录
`}
- ${d.timeline.length ? `
阶段流转附表 · 只读
${timeline}
` : ""}`; + ${d.timeline.length ? `
阶段流转附表 · 只读
${timeline}
` : ""} + ` : ""}`; const on = (sel, fn) => { const el = $(sel); if (el) el.addEventListener("click", fn); }; @@ -2125,28 +2150,25 @@ on("#cdSave", async () => { const btn = $("#cdSave"); const err = $("#cdErr"); - const ratioRaw = $("#cdRatio").value.trim(); btn.disabled = true; err.style.display = "none"; try { await patch("/custom/projects/" + cid, { name: $("#cdName").value.trim(), - stage: $("#cdStage").value, + origin: $("#cdOrigin").value.trim() || null, + project_code: $("#cdCode").value.trim() || null, + sign_date: $("#cdSign").value || null, + office: $("#cdOffice").value.trim() || null, + industry: $("#cdIndustry").value.trim() || null, + man_days: $("#cdManDays").value === "" ? null : Number($("#cdManDays").value), amount: $("#cdAmount").value === "" ? null : Number($("#cdAmount").value), - progress: Number($("#cdProgress").value), - revenue_amount: $("#cdRevenue").value === "" ? null : Number($("#cdRevenue").value), - manual_ratio: ratioRaw === "" ? null : Number(ratioRaw) / 100, - entry_date: $('#drawerBody [data-cf="entry_date"]').value || null, - sign_date: $('#drawerBody [data-cf="sign_date"]').value || null, - deliver_date: $('#drawerBody [data-cf="deliver_date"]').value || null, - accept_date: $('#drawerBody [data-cf="accept_date"]').value || null, - revenue_date: $('#drawerBody [data-cf="revenue_date"]').value || null, - invoice_amount: $("#cdInvoice").value === "" ? null : Number($("#cdInvoice").value), - received_amount: $("#cdReceived").value === "" ? null : Number($("#cdReceived").value), - region: $("#cdRegion").value, - owner: $("#cdOwner").value || null, - members: peopleValues("cdMembers"), - customer: $("#cdCustomer").value, + market_owner: $("#cdMarketOwner").value.trim() || null, + project_status: $("#cdStatus").value.trim() || null, + progress: $("#cdProgress").value === "" ? 0 : Number($("#cdProgress").value), + accept_date: $("#cdAcceptQuarter").value.trim() || null, + accept_plan: $("#cdAcceptPlan").value.trim() || null, + risk_level: $("#cdRisk").value.trim() || null, + accept_date_real: $("#cdAcceptReal").value || null, remark: $("#cdRemark").value, }); } catch (e) { @@ -2201,7 +2223,7 @@ ); } - /* ------------------------------------------------ 定制项目录入 */ + /* ------------------------------------------------ 定开项目录入 */ async function renderCustomIntake(host) { const projects = await api("/custom/projects"); const weeks = state.periods.weeks.slice().reverse(); @@ -2215,17 +2237,17 @@ host.innerHTML = `
- +
${intakeModeSegHtml()}
-

定制项目更新录入${intakeMode() === "ai" - ? "给已有定制项目记一期:粘贴原文 → AI 整理成 1 条(含阶段 / 金额 / 计收)→ 确认写入" - : "给已有定制项目记一期:内容原样存成 1 条,不拆解、不改写"}

- +

定开项目更新录入${intakeMode() === "ai" + ? "给已有定开项目记一期:粘贴原文 → AI 整理成 1 条(含阶段 / 金额 / 计收)→ 确认写入" + : "给已有定开项目记一期:内容原样存成 1 条,不拆解、不改写"}

+
-
1选择定制项目与周期
+
1选择定开项目与周期
-
项目按入库时间倒序排列。列表里没有要找的项目?点标题右侧「+ 新建定制项目」先建档,建好会自动选中回到这里。
+
项目按入库时间倒序排列。列表里没有要找的项目?点标题右侧「+ 新建定开项目」先建档,建好会自动选中回到这里。
2粘贴该项目的原始内容
@@ -2288,12 +2310,12 @@ on("#btnCDirect", commitCustomDirect); } - /** 定制交付 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */ + /** 定开项目 · 直接写入:内容一字不改存成 1 条执行记录,阶段 / 金额 / 计收一律不动 */ async function commitCustomDirect() { const raw = $("#cInRaw").value.trim(); if (!raw) return toast("请先填写内容"); const id = Number($("#cInProject").value); - if (!id) return toast("请选择定制项目"); + if (!id) return toast("请选择定开项目"); const pid = Number($("#cInPeriod").value); if (!pid) return toast("请选择周期"); const btn = $("#btnCDirect"); @@ -2325,7 +2347,7 @@ if (!raw) return toast("请先粘贴内容"); const sel = $("#cInProject"); const id = Number(sel.value); - if (!id) return toast("请选择定制项目"); + if (!id) return toast("请选择定开项目"); const btn = useAI ? $("#btnCParse") : $("#btnCRule"); const old = btn.innerHTML; @@ -2375,7 +2397,7 @@
- 正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定制交付」 + 正文用你的原文,AI 只补上面这些字段;写入后阶段、金额、计收同步更新到「定开项目」
`; $("#btnCCommit").addEventListener("click", commitCustomIntake); @@ -2451,7 +2473,7 @@ host.innerHTML = `
- +
${intakeModeSegHtml()}
@@ -2694,7 +2716,7 @@
- + ${weeks.map((w) => ` @@ -3110,7 +3132,7 @@
${esc(o.name)} 项目 ${o.project_count} - 定制 ${o.custom_count} + 定开 ${o.custom_count}
`).join("") : `
所有项目负责人都能对上花名册
`} @@ -3130,7 +3152,7 @@ const table = `
周期常规记录定制记录
周期常规记录定开记录
- + ${d.items.map((m) => ` @@ -3320,7 +3342,7 @@ const UI_KEY = "unis.ui.v1"; const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword", "projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived", - "customTab", "customArchived", "customRegion", "staffArchived", "staffGroup"]; + "customTab", "customArchived", "customRegion", "customOrigin", "staffArchived", "staffGroup"]; function saveUI() { const o = {}; diff --git a/web/icons.js b/web/icons.js index f230bca..c6c5e5f 100644 --- a/web/icons.js +++ b/web/icons.js @@ -2,7 +2,7 @@ 用法:Icon.svg("wallet") → 裸图标 Icon.badge("wallet") → 图标 + 重点色底板 Icon.status("in_progress") → 执行状态图标 - Icon.stage("signed") → 定制阶段图标 + Icon.stage("signed") → 定开阶段图标 Icon.pipe("entry") → 交付环节图标 静态标记里写 ,加载时自动替换成 SVG。 */ (function () { @@ -131,7 +131,7 @@ ["负责人", "users"], ["人员", "users"], ["人力", "users"], ["花名册", "users"], ["在册", "users"], ["区域", "layers"], ["归属", "layers"], ["分类", "pie"], ["明细", "list-checks"], ["待办", "list-checks"], ["本周新增", "inbox"], ["有变化", "refresh"], - ["在管项目", "layers"], ["定制项目", "package"], ["交付环节", "layers"], ["交付漏斗", "filter"], + ["在管项目", "layers"], ["定开项目", "package"], ["交付环节", "layers"], ["交付漏斗", "filter"], ["交付", "package"], ["验收", "shield-check"], ["入库", "inbox"], ["签单", "pencil"], ["阶段流转", "refresh"], ["阶段", "layers"], ["线索", "target"], ["开发", "code"], ["重点项目", "star"], ["重点", "star"], ["周期反馈", "calendar-check"], ["周期管理", "calendar"], diff --git a/web/index.html b/web/index.html index a048ff7..ed80203 100644 --- a/web/index.html +++ b/web/index.html @@ -20,7 +20,7 @@
姓名归属小组人员归属说明在管项目定制项目定制合同额在管项目定开项目定开合同额 名下待计收最近更新操作