Compare commits

...

2 Commits

Author SHA1 Message Date
chezhiqi 07e1b0ae7b 定开项目:改名(华智下单/进行中)+ 表格排序与筛选 + 列序/按钮调整
项目归属「新签订单」→「华智下单」(含数据迁移 83 条,导入脚本自动映射)
项目状态「交付中」→「进行中」(含数据迁移 10 条,导入脚本自动映射);项目归属去掉「其他」选项
本周进展卡片:本周新增项目→新增项目、交付项目→进行中项目
清空早于 2026-09-18 的「更新时间」(111 条),后续编辑按提交时间重新写入
表格:备注与更新时间列对调;下单时间/订单金额/更新时间 表头支持升/降序
去掉「删除本周更新」按钮(后端接口保留)
新增「推动验收计划」筛选,导出同步(export_custom 新增 accept_plan)
2026-09-20 22:08:00 +08:00
chezhiqi a77cd7bc81 定开项目:台账化改造 + 统计/导出/搜索调整,新增本周进展与年度任务 tab
模块改名「定制交付→定开项目」,导入《定开项目.xlsx》市场台账(扩展 origin/project_code/office/industry/man_days/market_owner/project_status/accept_plan/risk_level/update_date/accept_date_real 等字段)
项目归属新增「提前交付」;已下单口径=新签订单/存量项目/汇智直签,并新增「项目归属」筛选
表格对齐台账字段并新增「进度」列;编辑/新建表单同步;名称搜索改为回车查询
下线交付流水线/阶段看板/财务跟踪;tab 调整为 项目总览 / 本周进展 / 年度任务
本周进展:验收情况 / 本周新增项目(按「下单时间」)/ 交付项目
年度任务:季度计收目标(《年度任务.xlsx》)+ 按验收/下单时间回填
统计口径:明细金额用元、汇总用万;下单只统计已下单;验收按「验收时间」accept_date
更新时间=提交时间;导出 Excel 对齐明细表并跟随筛选
2026-09-20 17:39:32 +08:00
48 changed files with 1422 additions and 622 deletions

View File

@ -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 <url> .`)。
- 项目结构: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...`)。

View File

@ -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` 绑定。
- **状态筛选**:工具栏加 `<select id="cStatus">`(全部/已验收/待开发/交付完成/交付中/退单),state 加 `customStatus`,在 `renderCustomList` 内对已加载行做客户端筛选。
- **新建表单**:`openNewCustomDialog` 重写为对齐 Excel 的字段(项目归属/项目名称*/项目ID/下单时间/办事处/行业/市场下单人天/订单金额/市场责任人/项目状态/验收时间(季度文本)/推动验收计划/风险值/验收日期/备注/重点项目);提交时把台账字段发给后端,`region` 由后端按 office 归一化(实测 深圳→广东)。更新录入页共用此函数,一并生效。
- **统计条**:新增 `.stat-row/.stat`(styles.css),在 `renderCustomList` 表头上方展示 5 个格子:项目数量/合同额、本月新增下单、本年新增下单、本月验收、本年验收(均「个数 · 金额万」)。下单按 `sign_date`、验收按 `accept_date_real`,基于当前筛选后的行计算;card-head 的 period-hint 保留人天/高风险/已验收。
### 验证
- 语法:node `--check`(app.js)、ast.parse(models.py/custom.py/export.py/import_custom_ledger.py/constants.py)全通过。
- 接口:重启后 `/api/custom/projects` 119 条含 `accept_date_real`;POST 新建含全部台账字段 → region 由 office 推导正确(深圳→广东),测试项目已删除;`/api/export/custom` 200。
- 统计口径实测:本年验收 60、本月(2026-09)验收 17、本年新增下单 31、本月新增下单 0。
- 页面未做浏览器截图(agent-browser 未装),刷新浏览器查看即可。
## 明细表:去掉归档下拉 / 按更新时间排序 / 删除本周更新
用户(附截图:归档状态下拉「在库/已归档/全部」)要求:① 删除该下拉 ② 新增展示并「按更新时间排序」 ③ 可按周删除「本周更新」的数据。
### 前端(web/app.js)
- **移除**明细表 card-head 里的 `#cArchived` 归档状态下拉及 `bindCustomList` 里的绑定;`state.customArchived` 保留(恒为 "false",仍只查非归档)。
- **排序**:`renderCustomList` 的 `view` 改为按 `update_date` 倒序(`String(b.update_date).localeCompare(...)`),表头副标题改为「按「更新时间」倒序」。
- **删除本周更新**:card-head 加 `<button id="btnPurgeWeek" class="btn sm danger">删除本周更新</button>`;`purgeWeek()` 先 `GET /custom/weekly-preview` 拿数量/金额/名单 → `confirmDialog({danger:true, requireText:"删除"})` 二次确认(并要求手动输入「删除」)→ `POST /custom/purge-week` → `afterWrite()` 刷新。
- `confirmDialog` 增加可选 `o.requirePlaceholder`(原来输入框 placeholder 硬编码「逐字输入项目名称」,批量删除场景不适用)。
### 后端(app/routers/custom.py)
- 新增 `_sort_by_update()`(update_date 倒序,无则回落 entry_date);`GET /custom/projects` 改用之(原来是 `_sort_by_entry`)。
- 新增 `_week_range()`(当前自然周 周一~周日,基于 `date.today()` 的 `weekday()`)。
- 新增 `GET /custom/weekly-preview`(本周有 update_date 的项目数/金额/名单,供确认)。
- 新增 `POST /custom/purge-week`(删除 update_date 落在本周的项目,`db.delete(cp)` 靠 relationship cascade 连带删 custom_updates),返回 `{deleted,start,end,names}`。
- 路径用 `/custom/weekly-preview`、`/custom/purge-week`,**避开** `/custom/projects/{cid}`(int 路径参数会被非数字段 422),未放在 projects 前缀下。
### 验证
- node `--check` + ast OK;服务重启后 `/health` ok。
- `/api/custom/weekly-preview` → 本周 **2026-09-14 ~ 2026-09-20**,**119 个**(因导入把 119 行 update_date 都写成 2026-09-17=今天),合同额约 1130.7 万。
- `/api/custom/projects` 已按 update_date 倒序返回。
- ⚠️ 注意:当前所有行 update_date 都是导入当周,所以「删除本周更新」= 会删掉全部 119 条;已用「显示数量 + 需输入『删除』」做强确认。**未实际执行 purge**(只验证了 preview)。
## 定开金额单位:明细表用元、汇总统计用万
用户要求表格列「订单金额/万」去掉「/万」,单位换成正常的个位(元);但**汇总统计仍需用「万」**。
- **关键事实**:`custom_projects.amount` 在库里本来就是**元**(实测 sum≈11,307,268、avg≈95,019、max=1,000,000),之前的「/万」只是展示约定——`fen()`/`wan()` 把元÷10000 显示成万。
- **最终约定(定开金额)**:**明细表格每行 = 元**(`money(p.amount)`,¥+千分位);**所有汇总/统计 = 万**(`fen()`/`wan()`)。
- 改动(纯前端 `web/app.js`,静态服务无需重启):
- 定开项目明细表:表头 `订单金额/万` → `订单金额`;单元格 `fen(p.amount)` → `money(p.amount)`(元)。
- 上方统计条、人力资源页「定开合同额/定开待计收」、工作看板 KPI「定开合同额」、`删除本周更新`确认框的「合同额合计」——**仍用万**(`fen()`/`wan()`),未改/已改回。
- **未动**:项目管理(交付跟踪)模块的「合同额」仍用 wan/fen+万(另一套 `projects` 数据);导出沿用原始元值。
- 服务 `/health` ok,浏览器刷新即可见(static 带 no-cache+ETag)。
## 更新时间 = 下单时间 + 修改表单对齐台账字段
两项需求:(1) 更新时间全部改为项目下单时间;(2) 定开「修改」表单只保留《定开项目.xlsx》14 列表字段,删除交付跟踪类多余字段。
### 1) update_date = sign_date(全量)
- 备份:`data/board.db.bak-before-updatedate-sign-20260917_000000`。
- `UPDATE custom_projects SET update_date = sign_date`(119/119 行;0 个 null sign_date,91 个真日期 + 28 个文本如「2024年3月前」)。
- 后端 `app/routers/custom.py` PATCH:原本改任何字段就 `update_date=today`,改为 `update_date = cp.sign_date if cp.sign_date else cp.update_date`(跟随下单时间)。已用 API 实测:改 remark 后 update_date 仍为 2024-04-30(=sign_date),不是今天。
- `scripts/import_custom_ledger.py`:新增/更新行的 `update_date` 改默认取 `sign_date`(无则回退 args.date);同步改了文档注释。
- 副作用:「删除本周更新」现在按 sign_date 周判断,历史台账(2024/2025)不会命中本周 → 之前「会删光 119 条」的危险消失。
### 2) 修改表单对齐台账(前端 `web/app.js`)
- `openCustomDrawer` 的 edit 模式:section「定开项目信息」只放 Excel 14 列(项目归属/项目名称/项目ID/下单时间/办事处/行业/市场下单人天/订单金额/市场责任人/项目状态/验收时间/推动验收计划/风险值/备注)+ 验收日期 + 只读「更新时间(跟随下单时间)」。
- **删除的多余字段**:基础信息里的 阶段/开发进度/已计收/确认比例/交付环节/区域/交付负责人/团队成员/客户;收入确认里的 开票金额/已回款 编辑框(收入确认 KPI 只读区仅在 view 模式显示,edit 模式整段不渲染)。edit 模式只渲染 ledgerSec 表单,baseSec/收入确认/周期执行记录/阶段流转 仅 view 模式渲染。
- `cdSave` PATCH payload 精简为上述台账字段(不再传 stage/owner/region/members 等,未传字段不动;`set_people` 对 None 不改成员)。
- view 模式补充显示 下单时间、验收时间(之前漏显示)。
- **数据保护**:编辑表单的「下单时间」用 text 输入(非 date),因为 28 行 sign_date 是文本(date 控件会清空它们);新建对话框仍用 date。
- 重启 uvicorn 使后端生效(原 PID 40088 → 新进程);`node --check` 与 `ast.parse` 均通过;前端 static 无需重启。

View File

@ -0,0 +1,44 @@
# 2026-09-18
## 定开项目明细:4 项调整(更新时间 / 统计实时 / 搜索回车 / 季度口径)
用户提了 4 条:
### 1) 保存后「更新时间」= 提交时间
- 昨天把 PATCH 改成 `update_date = sign_date`;今天改回**提交时间**。
- `app/routers/custom.py`:`from datetime import date, datetime, timedelta`;
- PATCH:`if changed: cp.update_date = datetime.now().strftime("%Y-%m-%d %H:%M")`
- CREATE:`data["update_date"] = datetime.now().strftime("%Y-%m-%d %H:%M")`
- 实测:PATCH 项目 1(原 2024-04-30)→ `2026-09-18 11:21`(含时分),已在库中还原为 2024-04-30。
- 口径小结:导入/初始 = 下单时间;界面改/新建后 = 提交时间(`String(16)` 正好放「YYYY-MM-DD HH:MM」)。
### 2) 统计随修改实时更新
- 保存走 `afterWrite() → refresh() → renderCustom()`,统计条本就重算;用户观感「项目状态改了统计没变」的根因是**旧口径(验收看 accept_date_real)与项目状态无关**。
- 改为**验收口径 = 「项目状态 == 已验收」**,季度/年度按 `accept_date_real` 归属(无验收日期的计入本年)→ 改状态即可反映到「本季度/本年验收」。
### 3) 项目名称搜索改为「回车」触发
- `web/app.js` 的 `bindKeyword(id, opts)` 增加 `opts.enter`:为真时只在 **Enter**(或 type=search 的清除 ×)才查询,否则维持 320ms 防抖。
- 启用:`bindKeyword("cKeyword", { enter: true })`(定开)、`bindKeyword("pKeyword", { enter: true })`(项目)。fKeyword/stKeyword 维持实时。
### 4) 统计维度:本月 → 本季度
- `renderCustomList` 统计条:`本月新增下单 → 本季度下单(Y年Qn)`、`本月验收项目 → 本季度验收(Y年Qn)`;本年两项保留。
- 季季度按 1-3/4-6/7-9/10-12 划分;`ymOf()` 只认 `^\d{4}-\d{2}` 的日期(「2024年3月前」这类文本不参与)。
- 核对(2026 Q3):本季度下单 12 / 本年下单 31 / 本季度验收 17 / 本年验收 62。
### 其他
- 后端改了 `custom.py` → **重启 uvicorn**(新 PID 26784,旧 27492 已杀);前端 static 无需重启。
- `node --check web/app.js`、`ast.parse(custom.py)` 通过。
- 临时脚本 `scripts/_tmp_verify.py` 已删除。
## 定开明细:只留「项目明细」+ 统计全局化 + 导出对齐
用户提了 3 条:去掉其它几个板块;筛选/统计只按项目明细;本季度统计要覆盖全部项目且不随搜索变化,并优化样式。
1. **只保留「项目明细」**:删掉「交付流水线 / 阶段看板 / 财务跟踪」三个 tab,以及 `renderCustomBoard` / `renderCustomPipeline` / `renderCustomFinance` 三个函数(共删 235 行,脚本 `_tmp_rmviews.py` 完成)。`state.customTab` 固定为 `"list"`。
2. **筛选/统计只基于项目明细**:
- `renderCustom` 改为**一次取全部**(`/custom/projects?archived=X`,不再带 keyword/region);
- 列表在**客户端**按 关键字 + 区域 + 状态 过滤(`state.keyword/customRegion/customStatus`);
- 统计条基于 `allRows`(**全部项目**),**不受搜索/筛选影响**(加 `.stat-note` 说明文字)。
3. **导出 Excel 对齐「项目明细」**:`export_custom` 去掉 `stage` 参数、**新增 `status` 参数**;列改为 15 列(= 明细表列);改 `_sort_by_update` 排序;前端导出带 `archived/region/status/keyword`。实测 `status=已验收` → 64 行、表头 15 列正确(64 个已验收,其中 62 个有本年验收日期)。
4. **统计样式优化**:`.stat` 左侧 3px 色条 + 金额重点色(`--c`);分色:总量=`--primary`、下单=`--violet`、验收=`--amber`。
5. **清理**:删除未用的 `customQuery`、`state.customBoard/customStats`;归档确认文案去掉「阶段看板」措辞。
6. 重启 uvicorn(PID 38440);`node --check` / `ast.parse` / health 均通过。前端 static 无需重启。

View File

@ -0,0 +1,96 @@
# 2026-09-20
## 修正「本季度验收」统计口径 → 按「验收时间」
用户反馈本季度验收数据不对,要求按「验收时间」统计;并启动服务。
- **启动**:uvicorn 之前已停;重新用 `<PY> -m uvicorn app.main:app --host 127.0.0.1 --port 8770` 启动,`/health` ok。前端 static 无需重启。
- **排查**:`已验收` 共 64 条,全部带「验收时间」`accept_date`(季度文本):**Q1=8 / Q2=35 / Q3=21**。旧统计用 `accept_date_real`(真实日期),而 4 条 Q3 行(id 43 宜章县智慧城市、44 广安武胜智慧停车场、104 陕西-神木能源朱盖塔煤矿二期、120 解放碑地下环道AI智能视频巡检)`accept_date_real` 为空 → 本季度只算出 17,比实际 21 少 4。
- **修正**(`web/app.js` `renderCustomList`,静态文件):验收统计改为按 `accept_date`(Q1~Q4):
- `本季度验收` = `accept_date` == 当前季度(Q3)→ **21**
- `本年验收` = 有「验收时间」的全部(Q1/Q2/Q3)→ **64**
- 不再依赖 `accept_date_real`;新增 `qOf()` 归一化(trim + 大写 + `^Q[1-4]$`)。
- **现值**(2026-09-20,全部 120 个项目):项目数量/合同额 120 / 本季度下单 12 / 本年下单 32 / 本季度验收 21 / 本年验收 64。
- 备注:4 条 Q3 行的 `accept_date_real`(验收日期)仍为空,统计已不受影响;如需可补 2026-09-30。
## 定开项目 5 项新需求(进度 / 提前交付 / 归属筛选 / 下单口径 / 本周进展 tab)
- **进度字段**:`custom_projects.progress`(Float, NOT NULL, 默认 0)复用,编辑/新建表单加「进度(%)」输入(`cdProgress`/`ncProgress`,提交 `progress`);表格加「进度」列,仅当 **状态=交付中** 或 progress>0 时显示 `N%`,否则「—」。
- **项目归属新增「提前交付」**:统一常量 `CUSTOM_ORIGINS = [新签订单, 存量项目, 汇智直签, 提前交付, 其他]`;新建/编辑下拉都用它。
- **项目归属筛选**:工具栏加 `#cOrigin`(state.customOrigin,客户端过滤);导出也带 `origin` 参数(后端 `export_custom` 新增 `origin`)。
- **下单口径**:`CUSTOM_PLACED = [新签订单, 存量项目, 汇智直签]`(`isPlaced()`)。第 1 张统计卡改名「**已下单项目 / 合同额**」,「本季度下单 / 本年下单」也只统计已下单(提前交付不计入下单)。验收仍看 `accept_date`。
- **新增「本周进展」tab**:定开现在两个 tab —— **项目明细 / 本周进展**(`state.customTab`)。`renderCustomWeekly()`:按 `update_date` 落在本周(周一~周日)筛项目,统计 **本周更新项目 / 验收情况 / 下单情况 / 提前交付情况 / 进行中项目情况**(各自数量+金额;进行中=待开发/交付中/交付完成),下面用表格列出本周项目。
- **重构**:抽出 `customTableHtml(view)` 供两个 tab 共用(表格新增「进度」列);`renderCustom` 工具栏按 tab 切换(明细显示搜索/筛选/导出/新建,进展只显示周期)。
- **清理**:删掉 `openCustomDrawer` 里 `baseSec` 的死 edit 分支(重复 id:cdName/cdAmount/cdProgress/cdRemark 等)与不再用的 `stageOpts`。
- 现值(120 个项目,origin 分布:新签订单 83 / 存量项目 27 / 汇智直签 10,暂无提前交付);本周更新 5 个(id 23/43/44/104/120,均 已验收)。
- 后端改了 `export.py` → 重启 uvicorn;`node --check` / `ast.parse` / health / 导出(origin) / PATCH progress 均通过。
### 「本周进展」tab 二次调整(同日稍后)
- 去掉「本周更新项目」汇总卡(不再统计本周更新的数量/金额),只保留 验收 / 下单 / 提前交付 / 进行中 四张。
- 本周项目列表**去掉「操作」列**:`customTableHtml(view, withOps)` 加开关,本周进展传 `false`;改数据靠点项目名称打开详情。
- **「下单情况」改为「本周新增下单」**:按「下单时间」`sign_date` 落在本周统计(`signedWeek`),不再用「本周更新 ∩ 已下单」。其余三类(验收/提前交付/进行中)仍按「更新时间」在本周归属。
- 纯前端改动(`web/app.js`),无需重启;当前 09-14~09-20 本周新增下单 0 个。
### 「本周进展」tab 三次调整(同日再稍后)
- 「下单情况」→ **「本周新增项目」**:统计 本周更新 ∩(**已下单 或 提前交付**)——`isPlaced(p) || p.origin === "提前交付"`。
- **去掉「提前交付情况」卡**(提前交付并入「本周新增项目」,不再单独统计)。
- 「进行中项目情况」→ **「交付中项目」**:只统计 `project_status === "交付中"`(不再含 待开发/交付完成)。
- 现在本周进展只有 **3 张卡:验收情况 / 本周新增项目 / 交付中项目**。纯前端改动,无需重启。
### 修正「本周新增项目」口径(关键)
- 用户指出:**「本周新增项目」应按「下单时间」`sign_date` 统计**(本周新下单 / 提前交付的项目),**不能按「更新时间」的本周更新集**。
- 最终实现:`newDeal = allRows.filter(p => inWeek(p.sign_date) && (isPlaced(p) || p.origin === "提前交付"))`。
- 「验收情况」「交付中项目」仍按「更新时间」在本周的项目统计(用户此次未要求改)。
- 数据:2026-09-20 有 1 条 sign_date=2026-09-20(18,000 元)→ 本周新增项目 = 1。
### 「交付中项目」→「交付项目」
- 卡片改名 **「交付项目」**:统计**本周更新的**项目里 项目状态 ∈ {**交付中, 交付完成**} 的。
- 最终本周进展 3 张卡:**验收情况**(本周更新 ∩ 已验收)/ **本周新增项目**(按下单时间 sign_date 在本周 ∩ 已下单或提前交付)/ **交付项目**(本周更新 ∩ 交付中+交付完成)。纯前端改动,无需重启。
## 新增「年度任务」tab(数据源《年度任务.xlsx》)
- 用户提供 `C:/Users/Administrator/Desktop/年度任务.xlsx`,要求定开加一个 tab 展示年度任务,并按「验收/下单时间」回填。
- **Excel 结构**(Sheet1,A1:G6):列 = 季度 / 计收目标(万元)/ 计收项目数量 / 计收项目金额(万元) / 计收完成率 / 新增签单项目数量 / 新增签单项目数量金额(万元);行 = Q1~Q4 + 合计。目标值 **Q1 35 / Q2 225 / Q3 160 / Q4 260(合计 680)万元**。
- **读取方式**:`tencent-docs-routing` → `tencent-docs-sheetagent`(skill 不存在)→ `tencent-local-office-edit`(`edsdk.py` 可用,`open_file` 成功)但 `sheet_*` 一律报 **No workbook open**;按 skill 降级规则改用 **openpyxl** 直接读(已告知用户)。
- **实现**(纯前端 `web/app.js`):
- 新增常量 `CUSTOM_ANNUAL_TARGETS = {1:35,2:225,3:160,4:260}`(万元)。
- 定开第三个 tab **「年度任务」**(`state.customTab === "annual"`);`renderCustomAnnual(allRows)` 输出与原表同列的表格 + 合计行。
- **计收项目** = `accept_date`(验收时间,季度文本)== Qn;**新增签单** = `isPlaced` 且 `sign_date` 落在本年度该季度;金额 元→万元;**计收完成率** = 计收金额 ÷ 目标。
- **回填现值(2026)**:Q1 计收 8个/92.64万(264.7%)、签单 8个/33.06万;Q2 计收 35个/190.09万(84.5%)、签单 12个/79.65万;Q3 计收 21个/88.68万(55.4%)、签单 12个/39.75万;Q4 全 0;合计 计收 64个/371.41万(54.6%)、签单 32个/152.47万。
- 纯前端改动,无需重启;`node --check` 通过。
### tab 改名 + 列序调整
- 定开第一个 tab **「项目明细」→「项目总览」**(chip 与卡片标题「定开项目总览」都改)。
- `customTableHtml` 列序:把**「项目归属」移到「下单时间」之后**(新序:项目名称 / 项目ID / 下单时间 / 项目归属 / 办事处 / …)。项目总览与本周进展两个 tab 共用,故同时生效。
- 后端导出标题仍是「定开项目明细」(未改,用户只说 tab 名)。纯前端改动,无需重启。
## 推送到 develop-chezhiqi
- 仓库此前**未配置 git 身份**(唯一提交 e9854eb 作者是 mula.liu <mula.liu@163.com>)。按用户选择,设**本仓库** `user.name=chezhiqi` / `user.email=chezhiqi@unissense.tech`(邮箱取远端域名 unissense.tech,如不对可 amend)。
- 提交 **a77cd7b**「定开项目:台账化改造 + 统计/导出/搜索调整,新增本周进展与年度任务 tab」,包含 app/**、web/**、scripts/**(新增 `import_custom_ledger.py`)、README.md、run.sh、`data/board.db`、`.workbuddy/memory/**`(含被 git 跟踪的 `__pycache__/*.pyc`)。
- 已 push 到 `origin/develop-chezhiqi`(`e9854eb..a77cd7b`),本地与远端一致(远端还提示可发 PR main...develop-chezhiqi)。
- **有意未提交**:`data/board.db.bak-*` 4 个本地备份(保持 untracked)。
## 「项目归属」新签订单 → 华智下单
- 前端 `web/app.js`:`CUSTOM_ORIGINS` / `CUSTOM_PLACED` 里 `新签订单` → **`华智下单`**(连同新建对话框默认值、统计说明文案,全文替换 6 处,现 app.js 内已无「新签订单」)。
- **数据迁移**:`UPDATE custom_projects SET origin='华智下单' WHERE origin='新签订单'`(83 行);备份 `data/board.db.bak-before-origin-rename-20260920_213304`。现 origin 分布:华智下单 83 / 存量项目 27 / 汇智直签 10 / 提前交付 2。
- `scripts/import_custom_ledger.py`:读入行时把 `新签订单` 归一化为 `华智下单`(防再导入回退),并更新文档注释;`app/models.py` 注释同步。
- 纯前端 + 数据 + 脚本改动,**无需重启**(models.py 仅注释)。**本次未 push**(用户本轮只要求改名)。
## 三项改名/调整(项目状态 / 本周卡片 / 归属选项)
1. **项目状态「交付中」→「进行中」**(全量改名):
- `web/app.js` 全文 `交付中`→`进行中`(共 12 处:状态色映射、STATUS_OPTIONS、进度列判定、本周「交付项目」= 进行中+交付完成、新建/编辑 statusOpts、placeholder、注释)。
- **数据迁移** `UPDATE custom_projects SET project_status='进行中' WHERE project_status='交付中'`(10 行);备份 `data/board.db.bak-before-status-rename-20260920_213707`。现状态:已验收 64 / 待开发 29 / 交付完成 17 / **进行中 10** / 退单 2。
- `import_custom_ledger.py` 读入时 `交付中`→`进行中` 归一化;`models.py` 注释同步。
2. **本周进展卡片「本周新增项目」→「新增项目」**(`web/app.js` 卡片标题 + 说明文案)。
3. **项目归属去掉「其他」选项**:`CUSTOM_ORIGINS = [华智下单, 存量项目, 汇智直签, 提前交付]`(新建/筛选下拉都不再有「其他」);`models.py` / 导入脚本文档注释同步。数据中本来就没有 origin=其他 的行。
- 纯前端 + 数据 + 脚本改动,**无需重启**;`node --check` / `ast.parse` 通过。**本轮未 push**。
## 本周卡片改名 + 清空历史「更新时间」
1. **本周进展卡片「交付项目」→「进行中项目」**(`web/app.js` 卡片标题/说明/标题栏摘要;口径未变,仍是 本周更新 ∩ 进行中+交付完成)。
2. **清空早于 2026-09-18 的「更新时间」**:`UPDATE custom_projects SET update_date=NULL WHERE update_date IS NOT NULL AND update_date!='' AND update_date < '2026-09-18'` → 清空 **111** 行;保留 11 行(均为 2026-09-18 及以后编辑的,含 "YYYY-MM-DD HH:MM" 提交时间)。备份 `data/board.db.bak-before-clearing-updatedate-20260920_214254`。
- 影响:`update_date` 仅剩 11 行有值;「删除本周更新」现在只会命中这 11 行(都在本周)。后续「修改/新建」会按提交时间重新写入。
## 表格排序 + 计划筛选 + 列序/按钮调整
1. **列序**:`customTableHtml` 里把「备注」与「更新时间」**对调**(… 风险值 / **更新时间** / **备注** / 操作)。
2. **去掉「删除本周更新」按钮**:删除 `renderCustomList` 卡片头里的按钮、`bindCustomList` 里的绑定与整个 `purgeWeek()` 函数(后端 `/custom/weekly-preview`、`/custom/purge-week` 接口保留未删)。
3. **表头排序**:`下单时间`(sign_date) / `订单金额`(amount, 数值) / `更新时间`(update_date) 三个表头可点击排序,`state.customSort = {key, dir}`;点不同列默认「降序」,再点同列切换升/降;表头显示 ▲/▼,卡片副标题显示当前排序。
4. **新增「推动验收计划」筛选**:工具栏加 `#cAcceptPlan`(`state.customAcceptPlan`),选项取数据中实际出现过的值(Q3/Q4/27年/-- 等);导出也带 `accept_plan` 参数(后端 `export_custom` 新增 `accept_plan`)。
- `state` 新增 `customAcceptPlan` / `customSort`,均加入 `UI_FIELDS` 持久化。
- 后端 `export.py` 改动 → 重启 uvicorn(PID 23436);`node --check` / `ast.parse` 通过。实测导出 `accept_plan=Q3` 生效。**本轮未 push**。

View File

@ -0,0 +1,70 @@
# 项目长期记忆 · unis_manager
软件部关键任务追踪看板。FastAPI 后端 + 原生 JS 前端 + SQLite。
## 运行环境(Windows)
`run.sh` 是 macOS 专用(`open`、`/Users/jiliu/...` 绝对路径),Windows 上按以下方式手动跑:
- Python 解释器(已装好依赖):`C:/Users/Administrator/.workbuddy/binaries/python/envs/default/Scripts/python.exe`
- 装依赖:`<PY> -m pip install -r requirements.txt`
- 启动:`<PY> -m uvicorn app.main:app --host 127.0.0.1 --port 8770`
- 端口/配置来自 `.env`(`PORT=8770`)。DB 用 SQLite,落在 `data/board.db`,仓库里已带数据。
- 健康检查:`GET /health`;API 文档:`/api/docs`;首页:`/`。
## 关键结构
- `app/` 后端:`main.py`(入口,挂载 `/api` 子应用 + `/static`)、`models.py`、`schemas.py`、`constants.py`、`routers/{board,projects,custom,focus,weekly,staff,ai,export}.py`、`ai_parser.py`、`export.py`、`schema_patch.py`、`config.py`。
- `web/` 前端:`index.html` / `app.js` / `styles.css` / `icons.js`(零构建,no-cache + ETag)。
- `scripts/` 运维脚本:`init_db.py`、`import_excel.py`、`import_staff.py`、`migrate_custom.py`、`shift_year.py`、`fix_period_split.py`、`merge_project.py`、`add_entry_date.py`、**`import_custom_ledger.py`**(把《定开项目.xlsx》市场台账导入 `custom_projects`,支持 `--dry-run`/`--date`,按 项目ID→名称 匹配做 upsert + 变更比对)。
- `.workbuddy/memory/` 存每日工作日志(随仓库提交,2026-09-12 起)。
- `schema_patch.ensure_columns` 会在启动时自动给已有表补上模型里**新增的可空列**(`ALTER TABLE ... ADD COLUMN`),所以加字段只需改 `models.py`,不用写迁移脚本。
## 业务模型
四模块:① 工作看板(只读汇总 `/api/weekly`+`/api/focus`)② 项目管理(`projects`/`tasks`/`updates`)③ 定开项目(原「定制交付」,表 `custom_projects`/`custom_updates`)④ 每周重点工作(`weekly_focus`)。
三支撑页:人力资源(`staff`,全系统负责人唯一来源)、更新录入、设置。
②③ 两套独立项目体系,共用一个花名册。
### 定开项目的两套字段(2026-09-17 起)
`custom_projects` 同时承载两套口径,**互不覆盖**:
- **交付跟踪口径**(原有):`stage`(商务阶段)/`progress`/`revenue_amount`/`invoice_amount`/`received_amount`/`owner`(交付负责人,来自花名册)
- **市场台账口径**(2026-09-17 从《定开项目.xlsx》导入):`origin`(项目归属)/`project_code`(项目ID)/`office`(办事处)/`industry`(行业)/`man_days`(市场下单人天)/`market_owner`(市场责任人,销售侧,**不在花名册**)/`project_status`(已验收/待开发/交付完成/交付中/退单)/`accept_plan`(推动验收计划)/`risk_level`(1.高风险/2.低风险)/`update_date`(更新时间)。**`origin`(项目归属) 取值**:新签订单 / 存量项目 / 汇智直签 / **提前交付**(2026-09-20 增) / 其他;**`progress`**(Float, NOT NULL, 默认 0) 复用为「交付中」项目的**进度(%)**
映射要点:`下单时间→sign_date`、`办事处→region`(归一化)+`office`(原样)、`验收时间→accept_date`(存的是**季度文本** Q1..Q4,不是日期)、`备注→remark`。`accept_date` 与 `accept_plan` 互补(60+59=119)。
**`accept_date` vs `accept_date_real`**(2026-09-17 增):`accept_date` 存季度文本(Q1..Q4),只作展示;新增 **`accept_date_real`**(真实日期 YYYY-MM-DD)专门给「本月/本年验收」统计用。导入脚本会给「已验收 + 季度文本」的行补**当年季度末**占位(Q1→03-31…Q4→12-31),且**只在库中为空时补**,不覆盖界面手改的真日期。**统计口径(2026-09-20 定稿)**:下单看 `sign_date`(本季度/本年按日期归属);**验收看「验收时间」`accept_date`(季度文本 Q1~Q4)**——本季度验收 = `accept_date` 等于当前季度,本年验收 = 有验收时间(全部季度)。**不再用 `accept_date_real`**(它只作参考/展示,历史上有 4 条 Q3 行缺值,曾导致本季度验收少算)。
明细表(`renderCustomList`)列顺序对齐 Excel 14 列 + 更新时间 + 操作;行内**只有「修改」**(详情靠点名称;归档按钮已按要求去掉)。**定开视图有 3 个 tab:项目明细 / 本周进展 / 年度任务**(`state.customTab`;原交付流水线 / 阶段看板 / 财务跟踪已下线)。列表在客户端按 关键字 + **项目归属** + 区域 + 状态 过滤。统计条 `.stat-row` 基于**全部已下单项目**(不随搜索/筛选变化):**已下单项目 / 合同额** + 本季度下单 + 本年新增下单 + 本季度验收 + 本年验收——**已下单 = 项目归属 ∈ {新签订单, 存量项目, 汇智直签}**(`isPlaced()` / `CUSTOM_PLACED`),**提前交付不计入下单**。「本周进展」tab(`renderCustomWeekly`)列出「更新时间」在本周(周一~周日)的项目,上方 3 张卡:**验收情况**(本周更新 ∩ 已验收)/ **本周新增项目**(按「下单时间」`sign_date` 在本周 ∩ 已下单或提前交付)/ **交付项目**(本周更新 ∩ 交付中+交付完成)。「年度任务」tab(`renderCustomAnnual`)按《年度任务.xlsx》出表:季度 + 计收目标(万元,前端常量 `CUSTOM_ANNUAL_TARGETS` = Q1 35/Q2 225/Q3 160/Q4 260)+ 回填列——**计收项目**(`accept_date` == Qn)/ **新增签单**(`isPlaced` 且 `sign_date` 在本年该季度),金额元→万元,完成率 = 计收金额 ÷ 目标。表格 `customTableHtml(rows)` 两个 tab 共用,在「项目状态」后新增**进度**列(`progress`,仅 状态=交付中 或 >0 时显示 `N%`)。项目名称搜索框**回车才查询**(`bindKeyword(id,{enter:true})`;cKeyword 定开 / pKeyword 项目已启用)。导出 Excel 列与明细表一致(15 列),跟随 归档/区域/状态/关键字 筛选。`openNewCustomDialog`(新建,更新录入页共用)字段同样对齐 Excel,提交时 `region` 由后端按 `office` 归一化(深圳→广东)。
**`update_date`(更新时间)的语义**:只在台账字段**确有变化**时才刷新(无变化不刷新)。
- **取值**:导入/初始 = 该行「下单时间」`sign_date`(2026-09-17 全量刷过);界面「修改 / 新建」提交后 = **提交时间** `YYYY-MM-DD HH:MM`(2026-09-18 起)。字段是 `String(16)`,正好放「YYYY-MM-DD HH:MM」。
- 用于明细表按更新时间倒序 + 按周筛选/删除。
- 与 `updated_at`(TimestampMixin 自动时间戳,任何改动都变)不是一回事。
导入「市场责任人」等销售侧人名**不走花名册校验**,不要往 `staff` 里塞。
## 术语(2026-09-16 起)
模块③ 的正式称呼是 **「定开项目」**(旧称「定制交付」),界面统一用「定开」:定开项目 / 定开项目明细 / 定开合同额 / 定开记录 / 定开动态 / 定开待计收。`categories` 表里也有一行分类叫「定开项目」。
**但这些字面量必须保留「定制」,改了会坏数据或逻辑**:
- `"定制开发"` —— 历史 Excel 的项目容器名,是 `import_excel.py` / `migrate_custom.py` / `fix_period_split.py` / `add_entry_date.py` 里的匹配条件。
- `import_excel.py` 中 `(("定制","交付","项目支撑"), "定开项目")` 的元组键 —— 匹配 Excel 原始分组名,只有映射目标值是「定开项目」。
- `"定制产品组"` —— `staff.group` 的团队名,`constants.py:STAFF_GROUPS` 里有它,7 人属于该组。
代码标识符(`custom` / `custom_projects` / API 路由 / CSS 类名)保持英文原名,未随中文改名。
## 约定
- 当前开发分支:`develop-chezhiqi`(跟踪 `origin/develop-chezhiqi`;main 为发布线)。
- 改代码后重启服务即可(未开 DEV 热加载)。
- 动数据库前先备份:`cp data/board.db data/board.db.bak`。
- 周期标签格式 `9月第1周`,可写 `2027年1月第1周` 显式指定年份。
## Windows 运维小抄
- 查端口占用:`netstat -ano | grep "127.0.0.1:8770.*LISTENING"`
- 停服务:git bash 下 `taskkill //PID` 会被路径转换破坏参数,改用 PowerShell `Stop-Process -Id <PID> -Force` 更稳。
- 服务重启后 `/health` 的 `BOOT_ID` 会变,可据此确认是新进程。

View File

@ -8,12 +8,12 @@
|---|---|---|---|
| **① 工作看板** | 每天进来的第一屏 | 三类工作本周期谁更新了、谁没动,点一条从一览看到详情 | 只读汇总(`/api/weekly` + `/api/focus`) |
| **② 项目管理** | NEX云桌面、AI Agent、智飞无人机平台、内部运营… | 长期重点项目的迭代执行、子任务进展、风险阻塞 | `projects` / `tasks` / `updates` |
| **③ 定制交付** | 各省市客户定制子项目 | 商务阶段流转(签单→开发→交付→验收→计收)+ 按进度确认收入 | `custom_projects` / `custom_updates` |
| **③ 定开项目** | 各省市客户定开子项目 | 商务阶段流转(签单→开发→交付→验收→计收)+ 按进度确认收入 | `custom_projects` / `custom_updates` |
| **④ 每周重点工作** | 不值得单独建项目档案的短期活儿 | 一条就是一件事,面板里录,归在**录入周**这一周,跟到完成 | `weekly_focus` |
支撑页三个:**人力资源**(`staff`,全系统「负责人」的唯一来源)、**更新录入**(执行 / 交付记录的唯一写入口:粘贴原文 → AI 整理 → 确认写入)、**设置**(周期管理、AI 配置)。
②③ 是**两套彼此独立的项目体系**(数据表也完全分开),共用一个**人力资源库**:项目与定制项目的「负责人」都从花名册里选,人员进出只维护一处。④ 的条目不是项目,不挂子任务与执行记录。① 只做汇总与跳转,自己不落数据。
②③ 是**两套彼此独立的项目体系**(数据表也完全分开),共用一个**人力资源库**:项目与定开项目的「负责人」都从花名册里选,人员进出只维护一处。④ 的条目不是项目,不挂子任务与执行记录。① 只做汇总与跳转,自己不落数据。
## 周期管理
@ -24,7 +24,7 @@
| 入口 | 操作 |
|---|---|
| 顶部周期下拉 | 选「+ 新建周期…」 |
| 更新录入页(常规 / 定制都有) | 周期下拉选「+ 新建周期…」 |
| 更新录入页(常规 / 定开都有) | 周期下拉选「+ 新建周期…」 |
| 设置页 → 周期管理 | 「+ 新建周期」按钮 |
输入格式 `9月第1周`(也可写 `2027年1月第1周` 显式指定年份),年份按以下规则自动推断:
@ -60,7 +60,7 @@ cd /Users/jiliu/WorkSpace/unis_manager
打开 <http://127.0.0.1:8770>
首次运行 `run.sh` 会自动完成:选择带依赖的 Python 解释器 → 缺依赖则安装 → 建库 → 导入历史 Excel → 拆分定制交付项目,然后启动并打开浏览器。
首次运行 `run.sh` 会自动完成:选择带依赖的 Python 解释器 → 缺依赖则安装 → 建库 → 导入历史 Excel → 拆分定开项目,然后启动并打开浏览器。
### 启动方式与排错
@ -101,15 +101,15 @@ python -m uvicorn app.main:app --port 8770
| 区域 | 内容 |
|---|---|
| 顶部 KPI | 重点项目(含本期已更新数)/ 重点项目未更新 / **本周重点工作**(本周到期的重点工作条数,脚注「逾期 N 条 · 已完成 N 条」)/ 执行记录 / 定制合同额(含项目数与本期计收)/ 风险阻塞 |
| **左栏** | **三组**清单,顺序固定:① **本周工作重点**(列表,逾期的置顶)② **重点项目**(列表:`is_key` 的项目,另含本期补了执行记录的非重点项目,条目前用 ★ 区分)③ **定制项目**——**只给一个入口**「本周定制项目动态」,左栏不铺清单(100 多个项目会淹没看板),点开才在右栏列本周有动态的。组头显示条数与「几个已更新 / 几条逾期 / 本周动态几个」;每条带负责人、状态或阶段、本期摘要、风险条数,本期有记录的点亮标记 |
| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 8 期趋势;**定制项目入口 → 本周动态总览**(顶部四枚标签:新入库 / 有交付动态 / 所列合同额 / 本期计收,下面分「本周新增」「本周有变化」两段,每条带阶段、负责人、金额、本期计收与摘要),点某一条再下钻到该项目 → 交付环节五段条 / 财务跟踪四联卡 + 开票·回款行内输入 / 本期交付记录 / 更早的交付记录,改开票、回款、环节日期即时保存(改日期会自动推进商务阶段);每周重点工作 → 只读的说明与状态,要改点「修改」开抽屉 |
| 工具栏 | 「负责人」筛选、「导出本周期记录」,提示里带本期项目数与定制动态数;顶栏可切周 / 月视图(月视图自动汇总该月所有周)。「列入全部在途」开关(`all_custom`)不在工具栏,挪到了右栏定制总览的头部 |
| 顶部 KPI | 重点项目(含本期已更新数)/ 重点项目未更新 / **本周重点工作**(本周到期的重点工作条数,脚注「逾期 N 条 · 已完成 N 条」)/ 执行记录 / 定开合同额(含项目数与本期计收)/ 风险阻塞 |
| **左栏** | **三组**清单,顺序固定:① **本周工作重点**(列表,逾期的置顶)② **重点项目**(列表:`is_key` 的项目,另含本期补了执行记录的非重点项目,条目前用 ★ 区分)③ **定开项目**——**只给一个入口**「本周定开项目动态」,左栏不铺清单(100 多个项目会淹没看板),点开才在右栏列本周有动态的。组头显示条数与「几个已更新 / 几条逾期 / 本周动态几个」;每条带负责人、状态或阶段、本期摘要、风险条数,本期有记录的点亮标记 |
| **右栏** | 点左栏任意一条即可联动。重点项目 → 本期执行记录 / 进度与状态(含子任务)/ 近 8 期趋势;**定开项目入口 → 本周动态总览**(顶部四枚标签:新入库 / 有交付动态 / 所列合同额 / 本期计收,下面分「本周新增」「本周有变化」两段,每条带阶段、负责人、金额、本期计收与摘要),点某一条再下钻到该项目 → 交付环节五段条 / 财务跟踪四联卡 + 开票·回款行内输入 / 本期交付记录 / 更早的交付记录,改开票、回款、环节日期即时保存(改日期会自动推进商务阶段);每周重点工作 → 只读的说明与状态,要改点「修改」开抽屉 |
| 工具栏 | 「负责人」筛选、「导出本周期记录」,提示里带本期项目数与定开动态数;顶栏可切周 / 月视图(月视图自动汇总该月所有周)。「列入全部在途」开关(`all_custom`)不在工具栏,挪到了右栏定开总览的头部 |
| 统计概览 | 页面最底部的折叠区(原「执行面板」,已不占主导航位):周期活跃度柱状图、分类环形图、状态分布、进度 Top10、低进度预警、风险清单,数据来自 `GET /api/stats`;加载失败也不影响看板主体 |
数据分两路:`GET /api/weekly?period_id=|year&month&owner=&all_custom=` 一次返回重点项目清单 + 定制项目清单 + 顶部 KPI(含 `custom_new` 本期新入库数、`custom_updated` 本期有交付记录数);**「本周工作重点」不在 `/api/weekly` 里**,前端并发取 `GET /api/focus` 后合并,分堆也在这一步判定(按条目的**录入周**:本周录入的 + 更早周次标了「延期」又没做完的结转条目)。
数据分两路:`GET /api/weekly?period_id=|year&month&owner=&all_custom=` 一次返回重点项目清单 + 定开项目清单 + 顶部 KPI(含 `custom_new` 本期新入库数、`custom_updated` 本期有交付记录数);**「本周工作重点」不在 `/api/weekly` 里**,前端并发取 `GET /api/focus` 后合并,分堆也在这一步判定(按条目的**录入周**:本周录入的 + 更早周次标了「延期」又没做完的结转条目)。
定制项目的口径:`/api/weekly` 返回**本期有交付记录**、**被标为重点**或**本周期新入库**(`is_new`:入库日期落在本周期首末日之内)的定制项目;右栏总览默认只列「本周新增」与「本周有变化」两段,其余只在组头计数里出现,并在底部提示「另有 N 个在途定制项目本周没有动态」。勾选总览头部的「列入全部在途」后,后端补上处于在途阶段(商机 → 已验收)的项目,总览多出一段「其他在途项目」。头部那枚「合同额合计」只加总**当前列出来的行**,勾与不勾数字会跟着清单变。
定开项目的口径:`/api/weekly` 返回**本期有交付记录**、**被标为重点**或**本周期新入库**(`is_new`:入库日期落在本周期首末日之内)的定开项目;右栏总览默认只列「本周新增」与「本周有变化」两段,其余只在组头计数里出现,并在底部提示「另有 N 个在途定开项目本周没有动态」。勾选总览头部的「列入全部在途」后,后端补上处于在途阶段(商机 → 已验收)的项目,总览多出一段「其他在途项目」。头部那枚「合同额合计」只加总**当前列出来的行**,勾与不勾数字会跟着清单变。
### 2. 项目管理
@ -126,12 +126,12 @@ python -m uvicorn app.main:app --port 8770
| 操作 | 说明 |
|---|---|
| **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → AI 整理 → 确认写入),抽屉里不另做一套表单,避免两条路写出两份数据。基础信息的按钮在标题栏、附表的更新按钮在附表上方,两类按钮不在同一个位置混着放;编辑与新增档的报错信息显示在正文最上方;周期反馈上方的 按周/按月 + 本周/近 4 周/… 标成「查看范围(只读筛选)」,只翻看不改数据 |
| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定制交付同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 |
| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定制交付只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定制交付的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定制交付按交付负责人命中;团队成员不参与该筛选 |
| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定开项目同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 |
| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定开项目只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定开项目的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定开项目按交付负责人命中;团队成员不参与该筛选 |
| **归档** | 项目从看板与列表默认视图中隐藏,数据完整保留;勾选「含归档」可查看,随时可恢复 |
| **删除** | 高危操作:弹窗列出将连带删除的子任务数与执行记录数,并且必须**逐字输入项目名称**,「永久删除」按钮才会点亮。**删除后不可恢复**,仅想隐藏请用归档 |
> 定制交付的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。
> 定开项目的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。
#### 项目合并
@ -148,7 +148,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
- 合并后按最新记录回算目标项目的进度与状态
- 执行前建议备份:`cp data/board.db data/board.db.bak`
### 3. 定制交付
### 3. 定开项目
| 模块 | 说明 |
|---|---|
@ -156,7 +156,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
| **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 |
| **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 |
| **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 |
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定制交付并带上项目与周期)留在附表标题下 |
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定开项目并带上项目与周期)留在附表标题下 |
### 4. 每周重点工作
@ -168,7 +168,7 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
| 列表 | 工作内容 / 说明(截断预览)/ 预计完成时间(下面小字标「录入 X月第N周」)/ 状态(标了延期且没完成的挂红色「延期」徽章)/ 更新日期 / 操作 **详情 · 修改 · 归档 · 删除**——和两张项目列表同一套规矩:列表只读,行内不放输入框 |
| 筛选 | **本周 / 延期 / 已完成 / 全部** 四个堆(chip 上实时显示各堆条数)+「含归档条目」;顶栏切周即换基准周,工具栏标明「本周/延期按录入周统计」;顶部全局搜索同时匹配工作内容与说明 |
| 详情抽屉 | 同样分「详情 / 修改」两档,**基础信息的按钮全在抽屉标题栏右侧的按钮条上**:详情档是「删除 · 归档 · **修改**」,修改与新增档是「取消 · **添加/保存**」,报错信息显示在正文最上方。点「修改」才出表单,**录入周只读**(改预计完成时间不会把条目搬去别的周)。重点工作条目没有附表,所以抽屉里不出现绿的更新条 |
| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定制交付的项目删除上 |
| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定开项目的项目删除上 |
| 导出 | 右上角「导出 Excel」→ `GET /api/export/focus`,带上当前筛选条件 |
| 联动 | 工作看板左栏**第一组**「本周工作重点」与 KPI「本周重点工作」读的就是这张表;看板右栏点条目给只读详情,改内容走抽屉 |
@ -182,12 +182,12 @@ python scripts/merge_project.py --from "智空无人机平台" --to "智飞无
| 区块 | 说明 |
|---|---|
| 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) |
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定制项目数、定制合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定开项目数、定开合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
| 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 |
| 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `constants.STAFF_GROUPS` / `STAFF_AFFILIATIONS` |
| 改名 | 走「编辑」改姓名时,**名下项目与定制项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 |
| 改名 | 走「编辑」改姓名时,**名下项目与定开项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 |
| 归档 / 恢复 | 归档后不再出现在「负责人」下拉里,但历史项目仍完整保留其负责人;随时可恢复。离职人员用归档,不要用删除 |
| 删除 | 名下还挂着项目或定制项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 |
| 删除 | 名下还挂着项目或定开项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 |
导入名单(表头按名称匹配:序号 / 姓名 / 归属小组 / 人员归属 / 说明):
@ -198,14 +198,14 @@ python scripts/import_staff.py --reset # 清空后重新导入
python scripts/import_staff.py <xlsx> # 指定别的文件;首次运行 ./run.sh 会自动导入
```
**负责人下拉**(`ownerSelectHtml`)在项目新建/详情、定制项目新建/详情、工作看板等位置统一使用,选项来自花名册:
**负责人下拉**(`ownerSelectHtml`)在项目新建/详情、定开项目新建/详情、工作看板等位置统一使用,选项来自花名册:
已归档的人不再作为可选项出现,只有他本来就是这条记录的负责人时才带「(已归档)」标注出现一次;
历史数据里花名册没有的人名同样补一个「(不在花名册)」选项,保证打开老项目不会把负责人改没。
**多负责人与团队成员**复用同一个下拉(`peopleEditorHtml`,现在只有重点项目的抽屉用它):人名下拉**横向排开、自动换行**,选项同样只有花名册里的人,所以新增成员不需要另外维护一份名单;名单里的每一位都带 × 可删,界面上不区分主次。定制交付的负责人是普通的单个 `ownerSelectHtml` 下拉,只有团队成员用这套名单编辑器。
**多负责人与团队成员**复用同一个下拉(`peopleEditorHtml`,现在只有重点项目的抽屉用它):人名下拉**横向排开、自动换行**,选项同样只有花名册里的人,所以新增成员不需要另外维护一份名单;名单里的每一位都带 × 可删,界面上不区分主次。定开项目的负责人是普通的单个 `ownerSelectHtml` 下拉,只有团队成员用这套名单编辑器。
### 入库时间
定制项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**:
定开项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**:
- 优先取原始《项目明细表》台账中该项目所在的周(该列是稀疏填写的,导入时按周分组向下继承)
- 台账没有的,取《周工作情况总结》「定制开发」块中该项目首次出现的周
@ -252,18 +252,18 @@ python scripts/add_entry_date.py --force # 全部重算
| 位置 | 导出内容 | 接口 |
|---|---|---|
| 定制交付 | 定制项目明细(含区域、金额、进度、收入确认、入库时间、备注 + 合计行) | `GET /api/export/custom` |
| 定制交付 | 定制项目周期记录 | `GET /api/export/custom-updates` |
| 定开项目 | 定开项目明细(含区域、金额、进度、收入确认、入库时间、备注 + 合计行) | `GET /api/export/custom` |
| 定开项目 | 定开项目周期记录 | `GET /api/export/custom-updates` |
| 项目管理 | 项目管理清单(分类、负责人、状态、标签、子任务数等) | `GET /api/export/projects` |
| 工作看板 | 本周期(或本月)的执行记录明细 | `GET /api/export/updates` |
| 每周重点工作 | 重点工作清单(工作内容 / 说明 / 预计完成时间 / 录入周 / 执行状态 / 是否延期 / 结果反馈 / 更新) | `GET /api/export/focus` |
| 人力资源库 | 花名册(含在管 / 定制项目数、合同额、待计收) | `GET /api/export/staff` |
| 人力资源库 | 花名册(含在管 / 定开项目数、合同额、待计收) | `GET /api/export/staff` |
导出的 xlsx 带表头样式、冻结首行、自动筛选,金额列按千分位格式;文件名含导出日期。
## 收入确认口径
定制项目按**完工百分比法**确认收入:
定开项目按**完工百分比法**确认收入:
| 阶段 | 确认比例 |
|---|---|
@ -279,7 +279,7 @@ python scripts/add_entry_date.py --force # 全部重算
- **可确认收入** = 合同额 × 上表比例
- **待计收** = 可确认收入 − 已计收
- **待开票** = 合同额 − 已开票、**待回款** = 合同额 − 已回款(均不小于 0,随开票 / 回款金额自动更新)
- 开票与回款金额在项目详情抽屉、定制交付详情抽屉、工作看板右栏都能直接填
- 开票与回款金额在项目详情抽屉、定开项目详情抽屉、工作看板右栏都能直接填
- 项目上可填「手动确认比例」覆盖阶段推算(用于预付、分批收款等特殊情形)
- 比例定义见 `app/constants.py::CUSTOM_STAGES` 与 `custom_revenue_ratio()`,改一处即可全局生效
@ -292,10 +292,10 @@ Period(周期: 年/月/周, sort_key)
├── Task(子任务)
└── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文)
# 定制交付(独立)
# 定开项目(独立)
CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例,
已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档)
# 定制交付不做多负责人:other_owners 列保留在库里但不再读写
# 定开项目不做多负责人:other_owners 列保留在库里但不再读写
└── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步)
# 每周重点工作(独立:条目不是项目,不挂子任务与执行记录)
@ -318,7 +318,7 @@ LedgerItem(客户项目台账) # 只剩模型与 Excel 导入解析,界面
两套体系各有独立提示词:
- **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步
- **定制交付类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
- **定开项目类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
- **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态)
- 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
@ -346,11 +346,11 @@ app/
main.py FastAPI 入口(API 挂载在 /api,静态页面在 /)
config.py 配置与 .env 加载(DATABASE_URL、AI 参数)
db.py engine / Session(按 URL 自动适配方言)
models.py ORM 模型(项目 + 定制项目 + 每周重点工作 + 花名册)
models.py ORM 模型(项目 + 定开项目 + 每周重点工作 + 花名册)
constants.py 状态/阶段常量、收入确认比例、区域与阶段识别
ai_parser.py LLM 解析 + 本地规则兜底(通用 / 定制两套)
ai_parser.py LLM 解析 + 本地规则兜底(通用 / 定开两套)
routers/ board(周期/元数据/统计) · weekly(工作看板数据) · projects(项目管理) ·
custom(定制交付) · focus(每周重点工作) · staff(人力) · ai · export
custom(定开项目) · focus(每周重点工作) · staff(人力) · ai · export
web/ index.html · styles.css · app.js(零依赖,无构建步骤)
scripts/ init_db.py · import_excel.py · migrate_custom.py · import_staff.py · add_entry_date.py · merge_project.py · shift_year.py
data/board.db SQLite 数据库
@ -366,7 +366,7 @@ POST /api/periods 新增周期(传 label 如「9月第1周」
DELETE /api/periods/{id} 删除空周期(有记录则拒绝)
GET /api/board?period_id=|year&month 周期汇总(页面主链路不走它,统计走 /api/stats)
GET /api/stats?period_id=|year&month
GET /api/weekly?period_id=|year&month&owner=&all_custom= 工作看板:重点项目 + 定制项目本周动态 + KPI
GET /api/weekly?period_id=|year&month&owner=&all_custom= 工作看板:重点项目 + 定开项目本周动态 + KPI
(custom_new 本期新入库 / custom_updated 本期有交付记录;行上带 is_new)
(左栏第一组「本周工作重点」前端另取 /api/focus 合并)
GET /api/projects (?archived=false|true|all)
@ -374,7 +374,7 @@ POST /api/projects PATCH /api/projects/{id} DELETE /api/projects/{id}
(owners/members 传人名数组,整体替换:不传=不改,传 []=清空;owners[0] 回写 owner 列供归属统计,界面不再区分主次)
GET /api/categories POST /api/categories PATCH /api/categories/{id} DELETE /api/categories/{id}
# 定制交付
# 定开项目
GET /api/custom/meta 阶段字典 + 区域列表 + 交付环节(pipeline) + 卡点阈值(aging_days)
GET /api/custom/projects?stage=&region=&keyword=
POST /api/custom/projects PATCH /api/custom/projects/{id} DELETE /api/custom/projects/{id}

Binary file not shown.

Binary file not shown.

View File

@ -223,10 +223,10 @@ def llm_parse(raw: str, project_name: str, period_label: str, db) -> dict[str, A
# ==========================================================================
# 定制交付项目:阶段 / 金额 / 计收
# 定开项目:阶段 / 金额 / 计收
# ==========================================================================
CUSTOM_SYSTEM_PROMPT = """你是定制交付项目的管理助理,负责把项目周报原文整理成结构化记录。
定制项目关注的是商务阶段流转(商机→签单→开发→交付→验收→计收)与收入确认。
CUSTOM_SYSTEM_PROMPT = """你是定开项目的管理助理,负责把项目周报原文整理成结构化记录。
定开项目关注的是商务阶段流转(商机→签单→开发→交付→验收→计收)与收入确认。
请严格输出 JSON(不要 Markdown 代码块、不要解释),结构如下:
{
"stage": "developing",
@ -247,7 +247,7 @@ CUSTOM_SYSTEM_PROMPT = """你是定制交付项目的管理助理,负责把项
5. 不要编造原文没有的金额和数字。
"""
CUSTOM_USER_TEMPLATE = """定制项目:{project}
CUSTOM_USER_TEMPLATE = """定开项目:{project}
周期:{period}
当前阶段:{stage}

View File

@ -37,7 +37,7 @@ LEDGER_CATEGORIES = [
]
# --------------------------------------------------------------------------
# 定制项目(交付制,与平台产品类项目分开管理)
# 定开项目(交付制,与平台产品类项目分开管理)
# (code, 名称, 颜色, 收入确认基准比例)
# 开发中阶段:base + 0.4 * progress/100
# 其余阶段:直接使用 base
@ -74,7 +74,7 @@ def custom_revenue_ratio(stage: str, progress: float | None, manual_ratio=None)
# --------------------------------------------------------------------------
# 定制项目:文本 → 阶段 / 金额 识别
# 定开项目:文本 → 阶段 / 金额 识别
# --------------------------------------------------------------------------
CUSTOM_STAGE_RULES = [
(("暂停", "终止", "未达成一致", "搁置"), "paused"),
@ -259,6 +259,8 @@ REGION_ALIAS = {
"天津市": "天津",
"上海市": "上海",
"重庆市": "重庆",
"深圳": "广东",
"深圳市": "广东",
"未标注": None,
"": None,
}
@ -339,7 +341,7 @@ DEFAULT_CATEGORY_COLORS = [
# --------------------------------------------------------------------------
# 定制项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
# 定开项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
# key 环节代码(与 CUSTOM_STAGES 的阶段代码对齐,入库单独用 entry)
# name 环节名
# date_field 该环节的日期字段(CustomProject 上的属性名)

View File

@ -97,7 +97,7 @@ class TimestampMixin:
class Category(Base, TimestampMixin):
"""项目分类(如 平台产品 / AI / 定制交付 / 内部运营)。"""
"""项目分类(如 平台产品 / AI / 定开项目 / 内部运营)。"""
__tablename__ = "categories"
@ -374,7 +374,7 @@ class LedgerItem(Base, TimestampMixin):
class CustomProject(Base, TimestampMixin):
"""定制交付项目(与通用研发项目完全分离)。
"""定开项目(与通用研发项目完全分离)。
关注:商务阶段流转(签单 → 开发 → 交付 → 验收 → 计收)与按进度确认收入。
"""
@ -394,7 +394,7 @@ class CustomProject(Base, TimestampMixin):
revenue_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已计收金额
owner: Mapped[str | None] = mapped_column(String(64), nullable=True) # 交付负责人(单人)
other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 已停用:定制交付不做多负责人
other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 已停用:定开项目不做多负责人
members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员
entry_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 入库时间(YYYY-MM-DD)
sign_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 签单/下单时间
@ -413,6 +413,31 @@ class CustomProject(Base, TimestampMixin):
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
source: Mapped[str] = mapped_column(String(16), default="import", nullable=False)
# ---- 市场/销售侧台账字段(来源《定开项目.xlsx》,2026-09 起)----
origin: Mapped[str | None] = mapped_column(String(32), nullable=True) # 项目归属:华智下单/存量项目/汇智直签/提前交付
project_code: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True
) # 项目ID(市场编号,导入时用于去重)
office: Mapped[str | None] = mapped_column(String(64), nullable=True) # 办事处
industry: Mapped[str | None] = mapped_column(String(32), nullable=True) # 行业:安平/政府/企业/教育…
man_days: Mapped[float | None] = mapped_column(Float, nullable=True) # 市场下单人天
market_owner: Mapped[str | None] = mapped_column(
String(128), nullable=True
) # 市场责任人(销售侧;多人用 / 分隔,与花名册无关)
project_status: Mapped[str | None] = mapped_column(
String(32), nullable=True
) # 项目状态:已验收/待开发/交付完成/进行中/退单
accept_plan: Mapped[str | None] = mapped_column(
String(32), nullable=True
) # 推动验收计划(Q1..Q4 / 27年)
risk_level: Mapped[str | None] = mapped_column(String(32), nullable=True) # 风险值:1.高风险 / 2.低风险
update_date: Mapped[str | None] = mapped_column(
String(16), nullable=True, index=True
) # 更新时间 YYYY-MM-DD:该行数据最后一次变动的日期,用于按周查看变化
accept_date_real: Mapped[str | None] = mapped_column(
String(16), nullable=True, index=True
) # 验收日期 YYYY-MM-DD(真实日期):与 accept_date(季度文本)区分,用于「本月/本年验收」统计
updates: Mapped[list["CustomUpdate"]] = relationship(
back_populates="project", cascade="all, delete-orphan"
)
@ -445,6 +470,18 @@ class CustomProject(Base, TimestampMixin):
"is_key": self.is_key,
"archived": self.archived,
"source": self.source,
# 市场/销售侧台账字段
"origin": self.origin,
"project_code": self.project_code,
"office": self.office,
"industry": self.industry,
"man_days": self.man_days,
"market_owner": self.market_owner,
"project_status": self.project_status,
"accept_plan": self.accept_plan,
"risk_level": self.risk_level,
"update_date": self.update_date,
"accept_date_real": self.accept_date_real,
"update_count": len(self.updates),
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
}
@ -464,7 +501,7 @@ class CustomProject(Base, TimestampMixin):
class CustomUpdate(Base, TimestampMixin):
"""定制项目的阶段流转 / 周期执行记录(独立表)。"""
"""定开项目的阶段流转 / 周期执行记录(独立表)。"""
__tablename__ = "custom_updates"
@ -518,7 +555,7 @@ class CustomUpdate(Base, TimestampMixin):
class Staff(Base, TimestampMixin):
"""人力资源库成员。
项目管理 / 定制交付的负责人一律从这里选取;归档后不再出现在下拉里,
项目管理 / 定开项目的负责人一律从这里选取;归档后不再出现在下拉里,
历史记录中的人名保持不变。
"""

View File

@ -156,7 +156,7 @@ def create_period(payload: PeriodCreate, db: Session = Depends(get_db)):
@router.delete("/periods/{pid}")
def delete_period(pid: int, db: Session = Depends(get_db)):
"""删除周期。仅允许删除没有任何执行记录(通用 / 定制)的空周期。"""
"""删除周期。仅允许删除没有任何执行记录(通用 / 定开)的空周期。"""
from ..models import CustomUpdate
p = db.get(Period, pid)

View File

@ -1,4 +1,4 @@
"""定制交付项目 API(独立于通用项目体系)。
"""定开项目 API(独立于通用项目体系)。
关注:商务阶段流转 与 按进度确认收入。
"""
@ -6,7 +6,7 @@ from __future__ import annotations
import json
from collections import defaultdict
from datetime import date
from datetime import date, datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
@ -39,7 +39,7 @@ class CustomProjectIn(BaseModel):
progress: float = 0
manual_ratio: float | None = None
revenue_amount: float | None = None
owner: str | None = None # 交付负责人(定制交付为单人,多负责人只用于重点项目)
owner: str | None = None # 交付负责人(定开项目为单人,多负责人只用于重点项目)
members: list[str] | None = None # 团队成员
entry_date: str | None = None
sign_date: str | None = None
@ -50,6 +50,17 @@ class CustomProjectIn(BaseModel):
received_amount: float | None = None
remark: str | None = None
is_key: bool = False
# 市场/销售侧台账字段
origin: str | None = None
project_code: str | None = None
office: str | None = None
industry: str | None = None
man_days: float | None = None
market_owner: str | None = None
project_status: str | None = None
accept_plan: str | None = None
risk_level: str | None = None
accept_date_real: str | None = None # 验收日期(真实日期,区别于 accept_date 季度文本)
class CustomParseIn(BaseModel):
@ -102,13 +113,28 @@ def _sort_by_entry(projects):
return sorted(projects, key=lambda c: (_entry_key(c), c.id), reverse=True)
def _sort_by_update(projects):
"""按「更新时间」倒序(最近变动的在前);无更新时间的回落到入库时间。"""
def key(c):
d = (c.update_date or "").strip()
return (d if d else "", _entry_key(c), c.id)
return sorted(projects, key=key, reverse=True)
def _week_range(today: date | None = None) -> tuple[date, date]:
"""当前自然周(周一~周日)。"""
t = today or date.today()
start = t - timedelta(days=t.weekday())
return start, start + timedelta(days=6)
def _unique_name(db: Session, name: str, exclude_id: int | None = None):
"""同名会让录入按名称匹配时张冠李戴,禁止重名。"""
q = db.query(CustomProject).filter(CustomProject.name == name)
if exclude_id is not None:
q = q.filter(CustomProject.id != exclude_id)
if q.first():
raise HTTPException(400, f"已存在同名定制项目「{name}」,请换个名称")
raise HTTPException(400, f"已存在同名定开项目「{name}」,请换个名称")
# ---------------------------------------------------------------- 元信息
@ -170,7 +196,7 @@ def list_custom_projects(
if owner:
q = q.filter(CustomProject.owner == owner)
return [c.to_dict() for c in _sort_by_entry(q.all())]
return [c.to_dict() for c in _sort_by_update(q.all())]
@router.post("/custom/projects")
@ -181,8 +207,12 @@ def create_custom_project(payload: CustomProjectIn, db: Session = Depends(get_db
raise HTTPException(400, "项目名称不能为空")
data["name"] = name
data["region"] = normalize_region(data.get("region"))
# 新建表单只有「办事处」,没有单独的「区域」:用办事处归一化出区域
if not data.get("region") and data.get("office"):
data["region"] = normalize_region(data.get("office"))
if not data.get("entry_date"):
data["entry_date"] = date.today().isoformat()
data["update_date"] = datetime.now().strftime("%Y-%m-%d %H:%M")
_unique_name(db, name)
cp = CustomProject(**data, source="manual")
set_people(cp, None, payload.members)
@ -196,7 +226,7 @@ def create_custom_project(payload: CustomProjectIn, db: Session = Depends(get_db
def get_custom_project(cid: int, db: Session = Depends(get_db)):
cp = db.get(CustomProject, cid)
if not cp:
raise HTTPException(404, "定制项目不存在")
raise HTTPException(404, "定开项目不存在")
ups = (
db.query(CustomUpdate, Period)
.join(Period, Period.id == CustomUpdate.period_id)
@ -225,12 +255,15 @@ def get_custom_project(cid: int, db: Session = Depends(get_db)):
def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db)):
cp = db.get(CustomProject, cid)
if not cp:
raise HTTPException(404, "定制项目不存在")
raise HTTPException(404, "定开项目不存在")
allowed = (
"name", "customer", "region", "amount", "stage", "progress", "manual_ratio",
"revenue_amount", "invoice_amount", "received_amount", "owner",
"entry_date", "sign_date", "deliver_date", "accept_date", "revenue_date",
"remark", "is_key", "archived", "period_id",
# 市场/销售侧台账字段
"origin", "project_code", "office", "industry", "man_days",
"market_owner", "project_status", "accept_plan", "risk_level", "accept_date_real",
)
payload = payload or {}
if "name" in payload:
@ -242,12 +275,18 @@ def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db))
payload["name"] = new_name
if "region" in payload:
payload["region"] = normalize_region(payload.get("region"))
changed = False
for k, v in payload.items():
if k in allowed:
if getattr(cp, k) != v:
changed = True
setattr(cp, k, v)
# 团队成员:名单型字段,整体替换(不传即不改);负责人只有单人,走上面的 owner
set_people(cp, None, payload.get("members"))
_advance_stage(cp, payload)
# 「更新时间」= 本次提交时间:台账字段有变化时刷新,便于按周查看最新改动
if changed:
cp.update_date = datetime.now().strftime("%Y-%m-%d %H:%M")
db.commit()
db.refresh(cp)
return cp.to_dict()
@ -257,7 +296,7 @@ def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db))
def delete_custom_project(cid: int, db: Session = Depends(get_db)):
cp = db.get(CustomProject, cid)
if not cp:
raise HTTPException(404, "定制项目不存在")
raise HTTPException(404, "定开项目不存在")
name = cp.name
n_upd = len(cp.updates)
db.delete(cp)
@ -265,6 +304,51 @@ def delete_custom_project(cid: int, db: Session = Depends(get_db)):
return {"ok": True, "name": name, "deleted_updates": n_upd}
# ---------------------------------------------------------------- 按周删除
def _week_filtered(db: Session):
"""本周(周一~周日)有「更新时间」的定开项目。"""
start, end = _week_range()
rows = (
db.query(CustomProject)
.filter(
CustomProject.update_date.isnot(None),
CustomProject.update_date >= start.isoformat(),
CustomProject.update_date <= end.isoformat(),
)
.all()
)
return start, end, rows
@router.get("/custom/weekly-preview")
def weekly_preview(db: Session = Depends(get_db)):
"""本周有更新的项目清单(供「删除本周更新」二次确认)。"""
start, end, rows = _week_filtered(db)
return {
"start": start.isoformat(),
"end": end.isoformat(),
"count": len(rows),
"amount": round(sum(r.amount or 0 for r in rows), 2),
"names": [r.name for r in rows[:60]],
}
@router.post("/custom/purge-week")
def purge_week(db: Session = Depends(get_db)):
"""删除本周(周一~周日)更新的定开项目,连带其记录。破坏性操作,前端须二次确认。"""
start, end, rows = _week_filtered(db)
names = [r.name for r in rows]
for r in rows:
db.delete(r) # custom_updates 走 relationship cascade="all, delete-orphan"
db.commit()
return {
"deleted": len(names),
"start": start.isoformat(),
"end": end.isoformat(),
"names": names,
}
# ---------------------------------------------------------------- 阶段看板
@router.get("/custom/board")
def custom_board(
@ -459,7 +543,7 @@ def custom_stats(
def create_custom_update(payload: dict, db: Session = Depends(get_db)):
cp = db.get(CustomProject, payload.get("custom_project_id"))
if not cp:
raise HTTPException(404, "定制项目不存在")
raise HTTPException(404, "定开项目不存在")
u = CustomUpdate(
custom_project_id=payload["custom_project_id"],
period_id=payload["period_id"],
@ -504,7 +588,7 @@ def delete_custom_update(uid: int, db: Session = Depends(get_db)):
def custom_ai_parse(payload: CustomParseIn, db: Session = Depends(get_db)):
cp = db.get(CustomProject, payload.custom_project_id) if payload.custom_project_id else None
if payload.custom_project_id and not cp:
raise HTTPException(404, "定制项目不存在")
raise HTTPException(404, "定开项目不存在")
period = db.get(Period, payload.period_id) if payload.period_id else None
parsed, mode, error = ai_parser.parse_custom(

View File

@ -1,8 +1,8 @@
"""数据导出(Excel / xlsx)。
提供四类导出,均可带上页面当前的筛选条件:
- /export/custom 定制交付项目明细
- /export/custom-updates 定制项目周期记录
- /export/custom 定开项目明细
- /export/custom-updates 定开项目周期记录
- /export/projects 常规项目管理
- /export/updates 常规项目周期执行记录
- /export/staff 人力资源库(含项目负载)
@ -28,7 +28,7 @@ from ..models import (
WeeklyFocus,
)
from .board import worst_status
from .custom import _sort_by_entry
from .custom import _sort_by_entry, _sort_by_update
router = APIRouter()
@ -42,11 +42,13 @@ def _resp(content: bytes, filename: str) -> Response:
return Response(content=body, headers=headers)
# ---------------------------------------------------------------- 定制交付
# ---------------------------------------------------------------- 定开项目
@router.get("/export/custom")
def export_custom(
region: str | None = None,
stage: str | None = None,
origin: str | None = None,
status: str | None = None,
accept_plan: str | None = None,
keyword: str | None = None,
archived: str = Query("false", pattern="^(true|false|all)$"),
db: Session = Depends(get_db),
@ -58,50 +60,41 @@ def export_custom(
q = q.filter(CustomProject.archived.is_(True))
if region:
q = q.filter(CustomProject.region == region)
if stage:
q = q.filter(CustomProject.stage == stage)
if origin:
q = q.filter(CustomProject.origin == origin)
if status:
q = q.filter(CustomProject.project_status == status)
if accept_plan:
q = q.filter(CustomProject.accept_plan == accept_plan)
if keyword:
q = q.filter(CustomProject.name.contains(keyword))
projects = _sort_by_entry(q.all())
projects = _sort_by_update(q.all())
periods = {p.id: p for p in db.query(Period).all()}
# 列与「定开项目明细」表格一致
headers = [
"项目名称", "区域", "客户", "商务阶段", "负责人",
"合同额(元)", "入库时间", "签单时间", "交付时间", "验收时间", "计收时间",
"开发进度(%)", "收入确认比例(%)", "可确认收入(元)",
"已开票(元)", "待开票(元)", "已回款(元)", "待回款(元)",
"已计收(元)", "待计收(元)", "最近更新周期", "重点", "归档", "备注",
"项目归属", "项目名称", "项目ID", "下单时间", "办事处", "行业",
"市场下单人天", "订单金额(元)", "市场责任人", "项目状态",
"验收时间", "推动验收计划", "风险值", "备注", "更新时间",
]
rows = []
for c in projects:
d = c.to_dict()
p = periods.get(c.period_id)
rows.append(
[
c.origin or "",
c.name,
c.region or "",
c.customer or "",
_stage_name(c.stage),
c.owner or "",
c.amount,
c.entry_date or "",
c.project_code or "",
c.sign_date or "",
c.deliver_date or "",
c.office or "",
c.industry or "",
c.man_days if c.man_days is not None else "",
c.amount if c.amount is not None else "",
c.market_owner or "",
c.project_status or "",
c.accept_date or "",
c.revenue_date or "",
round(c.progress or 0, 1),
d["revenue_ratio"],
d["confirmed_amount"],
c.invoice_amount,
d["pending_invoice"],
c.received_amount,
d["pending_received"],
c.revenue_amount,
d["pending_amount"],
p.to_dict()["full_label"] if p else "",
"是" if c.is_key else "",
"是" if c.archived else "",
(c.remark or "").replace("\n", " ")[:200],
c.accept_plan or "",
c.risk_level or "",
(c.remark or "").replace("\n", " "),
c.update_date or "",
]
)
@ -110,27 +103,21 @@ def export_custom(
def total(key: str) -> float:
return round(sum(d.get(key) or 0 for d in dicts), 2)
blank = [""] * 24
totals = ["合计", "", "", f"{len(projects)} 个项目", ""]
totals += [total("amount")] # 合同额
totals += [""] * 6 # 五个环节日期 + 开发进度
totals += ["", total("confirmed_amount")] # 确认比例 / 可确认收入
totals += [total("invoice_amount"), total("pending_invoice")]
totals += [total("received_amount"), total("pending_received")]
totals += [total("revenue_amount"), total("pending_amount")]
totals += ["", "", "", ""]
rows.append(blank)
rows.append(totals)
rows.append([""] * 15)
rows.append(
["合计", f"{len(projects)} 个项目", "", "", "", "",
total("man_days"), total("amount"), "", "", "", "", "", "", ""]
)
content = build_xlsx(
"定制交付项目",
"定开项目明细",
headers,
rows,
widths=[46, 10, 18, 14, 12, 14, 12, 12, 12, 12, 12, 11, 14, 16, 14, 14, 14, 14, 14, 14, 18, 7, 7, 40],
title="定制交付项目明细(入库 → 签单 → 交付 → 验收 → 计收)",
money_cols=(6, 14, 15, 16, 17, 18, 19, 20),
widths=[10, 46, 18, 12, 10, 8, 13, 14, 14, 11, 11, 14, 10, 40, 18],
title="定开项目明细",
money_cols=(8,), # 订单金额(元)
)
return _resp(content, "定制交付项目明细")
return _resp(content, "定开项目明细")
@router.get("/export/custom-updates")
@ -171,14 +158,14 @@ def export_custom_updates(
)
content = build_xlsx(
"定制项目记录",
"定开项目记录",
headers,
rows,
widths=[18, 42, 10, 14, 70, 10, 16, 16, 30, 30],
title="定制项目周期记录",
title="定开项目周期记录",
money_cols=(7, 8),
)
return _resp(content, "定制项目周期记录")
return _resp(content, "定开项目周期记录")
# ---------------------------------------------------------------- 常规项目
@ -309,7 +296,7 @@ def export_staff(
headers = [
"序号", "姓名", "归属小组", "人员归属", "在管项目", "重点项目",
"定制项目", "定制合同额(元)", "定制待计收(元)", "最近更新", "说明", "归档",
"定开项目", "定开合同额(元)", "定开待计收(元)", "最近更新", "说明", "归档",
]
rows = []
for i, m in enumerate(members, 1):

View File

@ -2,7 +2,7 @@
和项目管理(Project + Update)分开:不用先建项目档案,一条就是一个活儿。
字段按需求——工作内容 / 说明 / 预计完成时间(日历日期)/ 执行状态 / 结果反馈 / 是否延期。
period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定制交付的周期归属同一个意思;
period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定开项目的周期归属同一个意思;
预计完成时间只存 due_date,不再按日期反查周期。本周录一条两周后要干的事,本周照样看得见。
「是否延期」是人工开关(delayed):更早周次录入、标了延期又没做完的,结转进当周视图。
录入整理在「每周重点工作」页,汇总在「工作看板」。

View File

@ -1,6 +1,6 @@
"""人力资源库:人员维护 + 人员负载。
项目 / 定制项目的「负责人」统一取这里的姓名,因此:
项目 / 定开项目的「负责人」统一取这里的姓名,因此:
· 改名时同步刷新已有项目的负责人字段;
· 被项目引用的人不允许直接删除,只能归档(归档后不再出现在下拉里)。
"""
@ -51,7 +51,7 @@ ALLOWED_PATCH = ("name", "group", "affiliation", "note", "sort_order", "archived
def _workload(db: Session) -> dict[str, dict]:
"""按姓名汇总在管项目 / 定制项目负载。"""
"""按姓名汇总在管项目 / 定开项目负载。"""
load: dict[str, dict] = {}
def slot(name: str) -> dict:
@ -283,7 +283,7 @@ def delete_staff(sid: int, force: bool = False, db: Session = Depends(get_db)):
if (n1 or n2) and not force:
raise HTTPException(
400,
f"「{m.name}」名下还有 {n1} 个在管项目 / {n2} 个定制项目。"
f"「{m.name}」名下还有 {n1} 个在管项目 / {n2} 个定开项目。"
f"离职人员建议直接「归档」(归档后不再出现在负责人下拉里,历史记录保留);"
f"确需删除请先转交这些项目。",
)

View File

@ -1,8 +1,8 @@
"""周看板:重点项目 + 定制项目的单周总览。
"""周看板:重点项目 + 定开项目的单周总览。
左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐:
· 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目
· 定制项目 = 打了 ★ 的定制项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」)
· 定开项目 = 打了 ★ 的定开项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」)
前端左栏只给一个入口,点开后在右栏按「本周新增 / 本周有变化」分组展示。
右栏点开某一条时再按需拉 /projects/{id} 或 /custom/projects/{id} 看全量历史。
工作看板左栏的「本周工作重点」不在本接口里:前端并发取 /api/focus 后合并成一组。
@ -108,7 +108,7 @@ def weekly(
}
)
# ---------------------------------------------------------------- 定制项目
# ---------------------------------------------------------------- 定开项目
cups_by_project: dict[int, list[CustomUpdate]] = defaultdict(list)
for u in (
db.query(CustomUpdate).filter(CustomUpdate.period_id.in_(period_ids)).order_by(CustomUpdate.id).all()

View File

@ -1,6 +1,6 @@
"""老库补列:create_all 只会建新表,不会给已存在的表加字段。
模型新增可空字段后(如定制项目的验收时间 / 开票 / 回款),启动时自动
模型新增可空字段后(如定开项目的验收时间 / 开票 / 回款),启动时自动
`ALTER TABLE ... ADD COLUMN` 补齐,不需要手写迁移脚本。
用法:在 `Base.metadata.create_all()` 之后调用 `ensure_columns(engine, Base.metadata)`。

Binary file not shown.

2
run.sh
View File

@ -60,7 +60,7 @@ if [ ! -f data/board.db ]; then
if [ -f "$XLSX" ]; then
echo "==> 导入历史数据:$XLSX"
"$PY" scripts/import_excel.py "$XLSX"
echo "==> 拆分定制交付项目"
echo "==> 拆分定开项目"
"$PY" scripts/migrate_custom.py
else
echo " (未找到 Excel 源文件,跳过历史数据导入:$XLSX)"

View File

@ -1,4 +1,4 @@
"""为定制项目补充『入库时间』字段并回填历史数据。
"""为定开项目补充『入库时间』字段并回填历史数据。
取值优先级(当前口径 = 原始数据的月/周第一天):
1. 原始 Excel 里该项目**最早出现**的周期 → 换算成该周期第一天

View File

@ -146,12 +146,12 @@ def main() -> None:
# ---------- Step 2: 现有 9月第1周 数据 → 9月第2周 ----------
moved_u = db.query(Update).filter(Update.period_id == p_last.id).all()
moved_c = db.query(CustomUpdate).filter(CustomUpdate.period_id == p_last.id).all()
print(f"[2] 9月第1周 现有数据 → 9月第2周:常规 {len(moved_u)} 条 / 定制 {len(moved_c)} 条")
print(f"[2] 9月第1周 现有数据 → 9月第2周:常规 {len(moved_u)} 条 / 定开 {len(moved_c)} 条")
for u in moved_u:
print(f" 常规#{u.id} {u.project.name}"
f"{'/' + u.task.name if u.task else ''}")
for c in moved_c:
print(f" 定制#{c.id} {c.project.name}")
print(f" 定开#{c.id} {c.project.name}")
# ---------- Step 3: 拆分 8月第4周 ----------
print(f"\n[3] 拆分 8月第4周(把属于 9月第1周 的内容移到 id={p_last.id})")
@ -162,7 +162,7 @@ def main() -> None:
plan_move, plan_new, plan_trim = [], [], []
for (proj, task_name), content_last in last_block.items():
if proj == "定制开发":
continue # 定制项目走 Step 4,不进常规表
continue # 定开项目走 Step 4,不进常规表
key = (proj, task_name)
u = cur_updates.get(key)
if u is None:
@ -195,8 +195,8 @@ def main() -> None:
for proj, task_name, content in plan_new:
print(f" 新建 {proj}/{task_name}({len(content)}字)")
# ---------- Step 4: 定制记录 ----------
print(f"\n[4] 定制记录归类")
# ---------- Step 4: 定开记录 ----------
print(f"\n[4] 定开记录归类")
cur_custom = db.query(CustomUpdate).filter(
CustomUpdate.period_id == p_prev.id).all()
last_names = {d for (_p, d) in last_block}
@ -208,7 +208,7 @@ def main() -> None:
custom_move.append(c)
for c in cur_custom:
tag = "→ 9月第1周" if c in custom_move else "留在 8月第4周"
print(f" 定制#{c.id} {c.project.name[:26]:28s} {tag}")
print(f" 定开#{c.id} {c.project.name[:26]:28s} {tag}")
# ---------- Step 5: 清理空的多余周期 ----------
junk = [
@ -289,8 +289,8 @@ def main() -> None:
p.status = "in_progress"
print(f" {p.name[:22]:24s} 进度={p.progress} 状态={p.status}")
# ---------- 重算受影响定制项目的入库时间 ----------
print("\n[7] 重算受影响定制项目的入库时间")
# ---------- 重算受影响定开项目的入库时间 ----------
print("\n[7] 重算受影响定开项目的入库时间")
from app.constants import period_first_day
cid_touched = {c.custom_project_id for c in moved_c} | {
c.custom_project_id for c in custom_move}
@ -317,7 +317,7 @@ def main() -> None:
for p in db.query(Period).order_by(Period.sort_key).all()[-5:]:
n1 = db.query(Update).filter(Update.period_id == p.id).count()
n2 = db.query(CustomUpdate).filter(CustomUpdate.period_id == p.id).count()
print(f" {p.year}年{p.month}月第{p.week}周 常规 {n1:3d} / 定制 {n2:3d}")
print(f" {p.year}年{p.month}月第{p.week}周 常规 {n1:3d} / 定开 {n2:3d}")
finally:
db.close()

View File

@ -0,0 +1,277 @@
"""把《定开项目.xlsx》的市场台账导入定开项目表(custom_projects)。
字段映射(表格列 → 数据库列):
| 表格列 | 数据库列 | 说明 |
|--------------|-----------------|------|
| 项目归属 | origin | 华智下单 / 存量项目 / 汇智直签 / 提前交付(「新签订单」自动映射为「华智下单」) |
| 项目名称 | name | 匹配键之一 |
| 项目ID | project_code | 匹配键(优先) |
| 下单时间 | sign_date | 日期取 YYYY-MM-DD,像「2024年3月前」这种文本原样保留 |
| 办事处 | office / region | office 存原样;region 归一化进区域词表,供「区域」筛选 |
| 行业 | industry | |
| 市场下单人天 | man_days | |
| 订单金额 | amount | |
| 市场责任人 | market_owner | 销售侧人员,与花名册无关,可「张宁/叶镇」这种多人写法 |
| 项目状态 | project_status | 已验收 / 待开发 / 交付完成 / 进行中 / 退单(「交付中」自动映射为「进行中」) |
| 验收时间 | accept_date | 存的是季度文本(Q1..Q4),不是日期 |
| 推动验收计划 | accept_plan | Q1..Q4 / 27年 |
| 风险值 | risk_level | 1.高风险 / 2.低风险 |
| 备注 | remark | |
| (脚本补) | update_date | 更新时间,跟随「下单时间」(sign_date),便于按周查看(sign_date 缺失时回退到导入日期) |
匹配与更新口径:
- 优先按「项目ID」匹配已有行;**有项目ID 时只按 ID 匹配**(表里存在同名不同 ID 的项目,
退回名称匹配会把它们互相覆盖)。只有项目ID 为空才退回按「项目名称」匹配。
- 同一行数据在一次运行内只匹配一个已有项目,避免重名互相吞掉。
- **命中**:只比较上面这些台账字段,有变化才写库,并把 `update_date` 刷成「下单时间」(sign_date);
没变化则整行不动(`update_date` 保持原值)。sign_date 缺失时回退到导入日期。
- **未命中**:新增,`update_date` = 下单时间(sign_date),`entry_date` 兜底取下单时间。
- 不触碰 stage / progress / 计收 / 开票 / 回款等业务字段。
用法:
python scripts/import_custom_ledger.py --dry-run # 只预演,不写库
python scripts/import_custom_ledger.py # 实际写入
python scripts/import_custom_ledger.py --date 2026-09-17 # 指定「更新时间」
python scripts/import_custom_ledger.py --xlsx "D:/定开项目.xlsx"
"""
from __future__ import annotations
import argparse
import re
import sys
from datetime import date, datetime
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import openpyxl # noqa: E402
from app.constants import normalize_region # noqa: E402
from app.db import SessionLocal # noqa: E402
from app.models import CustomProject # noqa: E402
DEFAULT_XLSX = str(Path.home() / "Desktop" / "定开项目.xlsx")
# 表格列名 → 数据库字段
COLMAP = {
"项目归属": "origin",
"项目名称": "name",
"项目ID": "project_code",
"下单时间": "sign_date",
"办事处": "office",
"行业": "industry",
"市场下单人天": "man_days",
"订单金额": "amount",
"市场责任人": "market_owner",
"项目状态": "project_status",
"验收时间": "accept_date",
"推动验收计划": "accept_plan",
"风险值": "risk_level",
"备注": "remark",
}
NUMERIC = {"man_days", "amount"}
# 参与「有没有变化」比较的字段(region 由 office 推导,一并比较)
TRACKED = list(COLMAP.values()) + ["region"]
def _text(value) -> str | None:
if value is None:
return None
s = str(value).strip()
return s or None
def _num(value) -> float | None:
if value is None:
return None
s = str(value).replace(",", "").strip()
if not s:
return None
try:
return float(s)
except ValueError:
return None
def _date_text(value) -> str | None:
"""下单时间:真日期 → YYYY-MM-DD;文本(如「2024年3月前」「2024/」)原样保留。"""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d")
if isinstance(value, date):
return value.strftime("%Y-%m-%d")
return _text(value)
def read_rows(xlsx: str) -> list[dict]:
wb = openpyxl.load_workbook(xlsx, data_only=True)
ws = wb.worksheets[0]
header = [_text(ws.cell(1, c).value) for c in range(1, ws.max_column + 1)]
missing = [name for name in COLMAP if name not in header]
if missing:
raise SystemExit(f"[x] 表格缺少这些列:{missing}\n 实际表头:{header}")
col = {field: header.index(name) + 1 for name, field in COLMAP.items()}
rows: list[dict] = []
for r in range(2, ws.max_row + 1):
raw = {field: ws.cell(r, c).value for field, c in col.items()}
if not any(v is not None and str(v).strip() for v in raw.values()):
continue
row: dict = {}
for field, value in raw.items():
if field in NUMERIC:
row[field] = _num(value)
elif field == "sign_date":
row[field] = _date_text(value)
else:
row[field] = _text(value)
if not row.get("name"):
print(f"[warn] 第 {r} 行没有项目名称,跳过")
continue
row["region"] = normalize_region(row.get("office"))
# 历史取值改名(2026-09-20):项目归属 新签订单 → 华智下单;项目状态 交付中 → 进行中
if row.get("origin") == "新签订单":
row["origin"] = "华智下单"
if row.get("project_status") == "交付中":
row["project_status"] = "进行中"
# 验收日期(真实日期,区别于 accept_date 季度文本):仅对「已验收」且验收时间为季度文本的项目,
# 补一个当年季度末占位,使「本年/本月验收」统计有数可算。用户后续在界面上改了真日期不会被覆盖。
row["accept_date_real"] = None
if row.get("project_status") == "已验收" and row.get("accept_date"):
m = re.match(r"Q([1-4])", str(row["accept_date"]))
if m:
q = int(m.group(1))
me = [(3, 31), (6, 30), (9, 30), (12, 31)][q - 1]
row["accept_date_real"] = f"{date.today().year}-{me[0]:02d}-{me[1]:02d}"
rows.append(row)
return rows
def apply_fields(cp: CustomProject, row: dict, only: list[str] | None = None) -> list[tuple[str, object, object]]:
"""把 row 的字段写到 cp 上,返回 (字段, 旧值, 新值) 的变更列表。"""
changes: list[tuple[str, object, object]] = []
for field in (only or list(row)):
if field not in row:
continue
old = getattr(cp, field)
new = row[field]
# 数值统一按 float 比,避免 1000000 / 1000000.0 被判成变化
if field in NUMERIC:
old_cmp = _num(old)
new_cmp = _num(new)
else:
old_cmp = old
new_cmp = new
if old_cmp != new_cmp:
changes.append((field, old, new))
setattr(cp, field, new)
return changes
def main() -> None:
ap = argparse.ArgumentParser(description="导入定开项目台账")
ap.add_argument("--xlsx", default=DEFAULT_XLSX, help="台账 Excel 路径")
ap.add_argument("--date", default=date.today().isoformat(), help="写入的「更新时间」YYYY-MM-DD,默认今天")
ap.add_argument("--dry-run", action="store_true", help="只预演,不写库")
args = ap.parse_args()
xlsx = Path(args.xlsx)
if not xlsx.exists():
raise SystemExit(f"[x] 找不到文件:{xlsx}")
rows = read_rows(str(xlsx))
print(f"==> 读取 {xlsx}")
print(f" 有效数据 {len(rows)} 行;「更新时间」口径 = {args.date}")
db = SessionLocal()
try:
by_code: dict[str, CustomProject] = {}
by_name: dict[str, CustomProject] = {}
for cp in db.query(CustomProject).all():
if cp.project_code:
by_code.setdefault(cp.project_code, cp)
by_name.setdefault((cp.name or "").strip(), cp)
matched_ids: set[int] = set()
inserted = updated = unchanged = 0
change_log: list[str] = []
for row in rows:
cp = None
code = row.get("project_code")
if code:
# 有项目ID 时只认 ID:表里存在同名不同 ID 的两个项目(如甘肃省水利厅二期),
# 退回按名称匹配会把它们互相覆盖。
cand = by_code.get(code)
if cand is not None and cand.id not in matched_ids:
cp = cand
else:
cand = by_name.get(row["name"])
if cand is not None and cand.id not in matched_ids:
cp = cand
if cp is None:
inserted += 1
change_log.append(f" [新增] {row['name']}({row.get('office') or '—'} / {row.get('project_status') or '—'})")
if args.dry_run:
continue
cp = CustomProject(name=row["name"], source="import")
cp.entry_date = row.get("sign_date") or args.date
cp.update_date = row.get("sign_date") or args.date
apply_fields(cp, row, [f for f in row if f != "name"])
db.add(cp)
db.flush() # 拿到 id,供 matched_ids
if cp.project_code:
by_code.setdefault(cp.project_code, cp)
by_name.setdefault(row["name"], cp)
matched_ids.add(cp.id)
continue
changes = apply_fields(cp, row, TRACKED)
matched_ids.add(cp.id)
# 验收日期:仅当库里还为空时补占位(用户已在界面填了真日期则不覆盖)
if cp.accept_date_real is None and row.get("accept_date_real"):
cp.accept_date_real = row["accept_date_real"]
changes.append(("accept_date_real", None, cp.accept_date_real))
if not changes:
unchanged += 1
continue
updated += 1
if not args.dry_run:
cp.update_date = row.get("sign_date") or args.date
detail = ";".join(f"{f} {old!r}→{new!r}" for f, old, new in changes)
change_log.append(f" [更新] {row['name']}:{detail}")
if args.dry_run:
db.rollback()
else:
db.commit()
print(f"\n{'[dry-run] 预演结果' if args.dry_run else '[ok] 导入完成'}")
print(f" 新增 {inserted} 行 / 更新 {updated} 行 / 无变化 {unchanged} 行")
if change_log:
print("\n明细:")
for line in change_log[:40]:
print(line)
if len(change_log) > 40:
print(f" ... 另有 {len(change_log) - 40} 条")
if not args.dry_run:
total = db.query(CustomProject).count()
print(f"\n 当前 custom_projects 共 {total} 行")
finally:
db.close()
if __name__ == "__main__":
main()

View File

@ -44,7 +44,7 @@ CATEGORY_RULES = [
(("无人机", "智飞"), "智能装备"),
(("AI", "智能体", "Agent", "大模型"), "AI 能力"),
(("NEX", "云桌面", "云屏", "平台开发", "UniFlow"), "平台产品"),
(("定制", "交付", "项目支撑"), "定制交付"),
(("定制", "交付", "项目支撑"), "定开项目"),
]

View File

@ -18,7 +18,7 @@ DEFAULT_CATEGORIES = [
("平台产品", 0),
("AI 能力", 1),
("智能装备", 2),
("定制交付", 3),
("定开项目", 3),
("内部运营", 4),
]

View File

@ -1,4 +1,4 @@
"""把「定制开发」从通用项目体系拆出,独立成定制交付项目。
"""把「定制开发」从通用项目体系拆出,独立成定开项目。
数据来源:
1. 《项目明细表》台账(区域 / 金额 / 类别 / 状态说明)
@ -151,13 +151,13 @@ def main():
total = len(by_name)
if args.dry_run:
db.rollback()
print(f"[dry-run] 将生成定制项目 {total} 个(台账 {created_from_ledger} + 子任务 {created_from_task}),迁移执行记录 {moved_updates} 条")
print(f"[dry-run] 将生成定开项目 {total} 个(台账 {created_from_ledger} + 子任务 {created_from_task}),迁移执行记录 {moved_updates} 条")
return
db.commit()
amount_sum = sum(c.amount or 0 for c in by_name.values())
print(f"[ok] 定制项目 {total} 个(台账 {created_from_ledger} / 子任务 {created_from_task})")
print(f"[ok] 定开项目 {total} 个(台账 {created_from_ledger} / 子任务 {created_from_task})")
print(f"[ok] 迁移执行记录 {moved_updates} 条 → custom_updates")
print(f"[ok] 合同金额合计 ¥{amount_sum:,.2f}")
from collections import Counter

View File

@ -80,7 +80,7 @@ def main():
print("!! 平移后出现重复周期,已回滚。请先清理重复的周期数据。")
return
# 定制项目的日期字段同步平移
# 定开项目的日期字段同步平移
date_changed = 0
for cp in db.query(CustomProject).all():
for field in ("sign_date", "deliver_date", "revenue_date"):

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
用法:Icon.svg("wallet") → 裸图标
Icon.badge("wallet") → 图标 + 重点色底板
Icon.status("in_progress") → 执行状态图标
Icon.stage("signed") → 定制阶段图标
Icon.stage("signed") → 定开阶段图标
Icon.pipe("entry") → 交付环节图标
静态标记里写 <i data-icon="wallet"></i>,加载时自动替换成 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"],

View File

@ -20,7 +20,7 @@
<nav class="nav" id="nav">
<button class="nav-item active" data-view="board"><span class="ni" data-icon="board"></span>工作看板</button>
<button class="nav-item" data-view="projects"><span class="ni" data-icon="list-checks"></span>重点项目</button>
<button class="nav-item" data-view="custom"><span class="ni" data-icon="package"></span>定制交付</button>
<button class="nav-item" data-view="custom"><span class="ni" data-icon="package"></span>定开项目</button>
<button class="nav-item" data-view="focus"><span class="ni" data-icon="target"></span>每周重点工作</button>
<div class="nav-label">支撑</div>
<button class="nav-item" data-view="staff"><span class="ni" data-icon="users"></span>人力资源</button>

View File

@ -219,6 +219,26 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.kpi.accent-amber { --c: var(--amber); }
.kpi.accent-red { --c: var(--red); }
/* 定开项目明细表上方的统计条(左侧色条 + 金额重点色) */
.stat-row {
display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
gap: 12px; padding: 2px 16px 16px;
}
.stat {
position: relative; overflow: hidden;
background: var(--panel); border: 1px solid var(--border);
border-radius: var(--radius); padding: 12px 16px 12px 18px; box-shadow: var(--shadow);
}
.stat::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--c, var(--primary));
}
.stat .stat-num { font-size: 20px; font-weight: 700; letter-spacing: -.4px; color: var(--text); line-height: 1.25; }
.stat .stat-money { color: var(--c, var(--primary)); }
.stat .stat-unit { font-size: 11px; font-weight: 500; color: var(--muted); margin: 0 2px; }
.stat .stat-label { font-size: 12px; color: var(--muted); margin-top: 5px; }
.stat-note { padding: 0 16px 14px; font-size: 12px; color: var(--muted); }
/* ---------------- board ---------------- */
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
.chip {
@ -267,7 +287,7 @@ button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-
.progress > i { display: block; height: 100%; background: var(--primary); border-radius: 99px; transition: width .4s; }
.progress-label { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 6px; }
/* ---------------- kanban (定制交付) ---------------- */
/* ---------------- kanban (定开项目) ---------------- */
.kanban { display: flex; gap: 12px; overflow-x: auto; padding-bottom: 12px; align-items: flex-start; }
.kcol {
flex: 0 0 236px; background: var(--panel); border: 1px solid var(--border);
@ -457,7 +477,7 @@ textarea.inp { height: auto; padding: 8px 10px; line-height: 1.6; }
.btn.danger.solid { background: var(--red); border-color: var(--red); color: #fff; font-weight: 600; }
.btn.danger.solid:hover { background: #b42318; color: #fff; }
.arch-badge { font-size: 11px; padding: 1px 7px; border-radius: 999px; background: #f1f3f7; color: var(--muted); white-space: nowrap; flex: none; }
/* 操作列固定在表格右侧:横向滚动时改名/归档/删除始终可见(项目管理、定制交付共用) */
/* 操作列固定在表格右侧:横向滚动时改名/归档/删除始终可见(项目管理、定开项目共用) */
table.tbl th.col-ops, table.tbl td.col-ops {
position: sticky; right: 0; background: var(--panel); box-shadow: inset 1px 0 0 var(--border);
}