v 0.0.1
commit
e9854eb19b
|
|
@ -0,0 +1,12 @@
|
||||||
|
# 复制自 .env.example,可按需修改(修改后重启服务生效)
|
||||||
|
|
||||||
|
# 数据库:默认 SQLite。迁移 MySQL 时取消下一行注释并填入连接串
|
||||||
|
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/unis_board?charset=utf8mb4
|
||||||
|
|
||||||
|
# AI 整理(OpenAI 兼容协议)。留空 Key 时自动使用本地规则解析
|
||||||
|
AI_API_BASE=https://api.deepseek.com/v1
|
||||||
|
AI_API_KEY=
|
||||||
|
AI_MODEL=deepseek-chat
|
||||||
|
AI_TEMPERATURE=0.2
|
||||||
|
|
||||||
|
PORT=8770
|
||||||
|
|
@ -0,0 +1,15 @@
|
||||||
|
# ---- 数据库 ----
|
||||||
|
# 默认 SQLite(零配置)。迁移 MySQL 时改成:
|
||||||
|
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/unis_board?charset=utf8mb4
|
||||||
|
DATABASE_URL=sqlite:///./data/board.db
|
||||||
|
|
||||||
|
# ---- AI 整理(OpenAI 兼容协议)----
|
||||||
|
# DeepSeek / 通义千问(兼容模式) / Moonshot / 智谱 / OpenAI / 本地 Ollama 均可
|
||||||
|
# 留空 AI_API_KEY 时,系统自动降级为「本地规则解析」,功能仍可用
|
||||||
|
AI_API_BASE=https://api.deepseek.com/v1
|
||||||
|
AI_API_KEY=
|
||||||
|
AI_MODEL=deepseek-chat
|
||||||
|
AI_TEMPERATURE=0.2
|
||||||
|
|
||||||
|
# ---- 服务 ----
|
||||||
|
PORT=8770
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
# 2026-09-12
|
||||||
|
|
||||||
|
## 构建「软件开发部关键任务追踪看板」
|
||||||
|
|
||||||
|
- 数据源:`/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx`
|
||||||
|
- `周工作情况总结`:稀疏三层结构,时间(周期) → 工作内容(项目) → 内容说明(子任务) → 完成情况(执行记录);仅填完成情况的行是上一条记录的续行,需合并。
|
||||||
|
- `项目明细表`:客户项目台账(名称/区域/金额/类别/状态说明),部分行 F/G 列错位,需按类别白名单校验。
|
||||||
|
- 数据跨度 2024-03 ~ 2025-08,共 49 个周期、84 个项目、428 子任务、482 条执行记录、39 条台账。
|
||||||
|
- 技术栈:FastAPI + SQLAlchemy 2.0(SQLite,DATABASE_URL 可切 MySQL)+ 零依赖原生前端(无构建)。
|
||||||
|
- 关键实现:
|
||||||
|
- `Period.sort_key = year*10000 + month*100 + week`,月视图按 (year, month) 聚合周周期,不单独存月记录。
|
||||||
|
- 导入时自动归档:最近 10 个周期内无更新且非重点项目 → `archived=True`(归档 77 个,活跃 7 个)。
|
||||||
|
- 状态判定 `_guess_status`:先负面关键词 → 再用百分比(>=100 才 done)→ 最后才用「完成」类词;进度 <100 时强制把 done 降为 in_progress。
|
||||||
|
- AI 整理:OpenAI 兼容协议 + 本地规则兜底(无 Key 也可用)。
|
||||||
|
- 坑:
|
||||||
|
- SQLAlchemy `Table()` 必须传 `Column` 对象,不能传元组。
|
||||||
|
- 后台起服务要用 Bash 的 `run_in_background`,用 `(&)` 脱离会被沙箱杀掉。
|
||||||
|
- 前端 `donutChart` 需要 `{name,color,value}`,而接口 `category_dist` 给的是 `count`,必须映射。
|
||||||
|
- 启动:`./run.sh`(端口 8770),重启后需重新执行 `scripts/import_excel.py --reset` 才会应用新的解析规则。
|
||||||
|
|
||||||
|
## 拆分定制交付项目(第二版需求)
|
||||||
|
|
||||||
|
- 用户要求:定制项目关心每个子项目的交付阶段(签单/开发/交付完成/计收)与按进度统计收入,且与常规项目数据表分开。
|
||||||
|
- 新增独立表:`custom_projects`(区域/客户/合同额/阶段/开发进度/手动确认比例/已计收/签单·交付·计收日期)+ `custom_updates`(周期/阶段/本期新增下单/本期新增计收)。
|
||||||
|
- 迁移脚本 `scripts/migrate_custom.py`:
|
||||||
|
- 《项目明细表》39 条台账 + 原「定制开发」92 个子任务 → 合并成 119 个 CustomProject(同名合并,金额互补)。
|
||||||
|
- 子任务名尾部的「(39,400.00)」正则解析为合同额(`normalize_custom_name`)。
|
||||||
|
- 常规表里的「定制开发」容器整体删除,常规看板只剩 6 个平台产品类项目。
|
||||||
|
- 执行前先 `cp data/board.db data/board.db.bak`。
|
||||||
|
- 收入确认(完工百分比法):商机 0 / 签单 10% / 开发中 10%+40%×进度 / 交付完成 70% / 已验收 90% / 已计收 100% / 暂停 30%;`manual_ratio` 可覆盖。定义在 `app/constants.py`。
|
||||||
|
- 阶段判定坑:文本同时含「已计收」和「进度 60%」时必须判为 developing,否则会被关键词带偏 → `guess_custom_stage` 采用「负面 → 完成 → 在制 → 商务」顺序,并用 `extract_percent` 辅助。
|
||||||
|
- 区域:从项目名反推省/直辖市(`infer_region`,REGION_KEYWORDS 表),回填 68 个,剩 18 个未标注。
|
||||||
|
- 现状:合同总额 395 万,按进度可确认 99.8 万(25.3%),已计收为 0(历史数据无计收金额,需后续维护)。
|
||||||
|
|
||||||
|
## 启动脚本修复
|
||||||
|
|
||||||
|
- 用户报 `./run.sh` → `zsh: permission denied`;`sudo ./run.sh` → `command not found`。原因是文件权限为 644(无 +x),sudo 不会在当前目录找脚本。**不能用 sudo**(会把 data/board.db 变成 root 属主)。
|
||||||
|
- `chmod +x run.sh` 解决;同时重写 run.sh 使其健壮:
|
||||||
|
- 自动挑选带依赖的 Python 解释器(`.venv` → workbuddy venv → python3 → /usr/bin/python3),都没有依赖则 pip install。
|
||||||
|
- 启动前用 `/health` 探测端口,已在运行则直接打印地址并退出(避免重复起进程报错)。
|
||||||
|
- 首次运行才走建库 + import_excel + migrate_custom。
|
||||||
|
- 开关:`PORT=` 换端口、`DEV=1` 开 --reload、`NO_OPEN=1` 不自动开浏览器。
|
||||||
|
- 默认不以 --reload 启动(日常使用更稳),需要热重载时用 DEV=1。
|
||||||
|
|
||||||
|
## 新增周期能力 + 年份整体平移
|
||||||
|
|
||||||
|
- 需求:录入新周期数据时能新增周期(如「9月第1周」);把数据里 2024→2025、2025→2026(用户原话「2024年改为20205年」为笔误,按整体 +1 理解)。
|
||||||
|
- 已执行 `python scripts/shift_year.py`:49 个周期全部 +1 年,2024→2025 / 2025→2026,sort_key 重算且校验无重复;执行前备份 `data/board_before_year_shift.bak`。
|
||||||
|
- **关键坑**:2024 与 2025 年份同时存在,必须按年份从大到小更新,否则 2024→2025 会撞上尚未更新的 2025。
|
||||||
|
- 周期标签解析统一到 `app/constants.py::parse_period_label`(正则放宽以支持「2025/08-第1周」这种没有「月」字的写法),`scripts/import_excel.py` 改为复用,去掉了重复实现。
|
||||||
|
- `POST /api/periods` 年份推断规则:**只比较月份**。month >= 最新周期月份 → 同年;month < 最新周期月份 → 次年。
|
||||||
|
- **踩过的坑**:最初用 `(month, week) <= (latest.month, latest.week)` 判断,导致「9月第1周」在已有「9月第1周」时被判为跨年,重复创建出 2027 年的周期。改为只比月份后,补录同月更早周次也能正确留在同年。
|
||||||
|
- `DELETE /api/periods/{id}` 只允许删除无任何记录(通用 + 定制)的空周期;设置页新增「周期管理」表格,列出各周期记录数并提供删除按钮。
|
||||||
|
- 前端三个新建入口:顶部周期下拉、更新录入页(常规/定制各一个)周期下拉、设置页周期管理,都带「+ 新建周期…」。
|
||||||
|
- 当前状态:50 个周期(2025 年 30 个 / 2026 年 20 个),最新为 2026年9月第1周。
|
||||||
|
|
||||||
|
## 项目合并 + 项目库增删改(2026-09-13 补记)
|
||||||
|
|
||||||
|
- 需求:把「智空无人机平台」并入「智飞无人机平台」;项目库提供更名 / 删除 / 归档。
|
||||||
|
- 新增通用合并脚本 `scripts/merge_project.py --from A --to B`(备份 `data/board_before_merge.bak`)。
|
||||||
|
- **三个必须记住的坑**:
|
||||||
|
1. `Project`/`Task` 的 relationship 带级联,直接 `db.delete(task)` 会把它名下 Update 的外键**置空**(Task.updates 没配 cascade → 默认 nullify)。所以执行记录改挂一律用 bulk `db.query(Update).filter(...).update({...}, synchronize_session=False)`,不经过 ORM 关系。
|
||||||
|
2. session 是 `autoflush=False`,改完不 flush 就查询会读到旧数据(第一版统计出"孤儿记录 8 条",实际是重复遍历同一批对象)。改完必须显式 `db.flush()`。
|
||||||
|
3. 删源项目前要 `db.expire_all()`,否则 identity map 里的旧关系对象会被级联误删已转挂的数据。
|
||||||
|
- 结果:归并同名子任务 7 个、迁移独有子任务 32 个,执行记录总数 454 条前后一致(无丢失无重复);子任务 428→329。
|
||||||
|
- 项目库功能:表格行内加 `更名 / 归档 / 删除`,抽屉里也有同名操作;定制交付的项目明细表与抽屉同步支持。
|
||||||
|
- 后端 `PATCH /projects/{id}` 增加名称非空 + 重名校验(400 明确提示);`DELETE` 返回 `{name, deleted_tasks, deleted_updates}` 供前端提示。
|
||||||
|
- 删除是硬删除且级联,已用孤儿检查确认干净(tasks/updates/custom_updates 均无悬空外键)。
|
||||||
|
- 查孤儿数据时踩到:SQL 的 `LIKE '__%'` 里 `_` 是单字符通配符,会匹配所有记录;要查字面下划线得转义或在 Python 侧过滤。
|
||||||
|
|
||||||
|
## 入库时间 / 区域规范 / Excel 导出
|
||||||
|
|
||||||
|
- 需求三件套:定制明细加入库时间、区域改规范下拉并支持筛选、提供 Excel 导出。
|
||||||
|
- **入库时间 `custom_projects.entry_date`**(口径:该项目最早记录所在周期的第一天)
|
||||||
|
- 回填脚本 `scripts/add_entry_date.py`(备份 `data/board_before_entry_date.bak`),119/119 全覆盖,范围 2025-04-14 ~ 2026-08-17。
|
||||||
|
- 取值优先级:Excel《项目明细表》台账周(优先,项目正式登记入册的地方)→《周工作情况总结》定制开发块 → custom_updates 最早周期 → period_id → 创建日期。
|
||||||
|
- **踩坑**:一开始只从 custom_updates/period_id 取,结果大部分落到"创建日期"——因为当初导入台账时周期列(合并单元格的稀疏列)**没做向下继承**,period_id 大多是 NULL。改为回查原始 Excel 才补全。
|
||||||
|
- 周期→日期:`period_first_day()`,以该月第一个周一为第 1 周,第 N 周顺延 7×(N-1) 天。2026-08-01 是周六 → 8月第1周是 08-03,8月第4周是 08-24。
|
||||||
|
- **区域规范化**:`REGION_GROUPS` 定义 34 个省级行政区并按大区分组(华北/东北/华东/华中/华南/西南/西北),下拉用 optgroup;筛选下拉只列实际有数据的区域。用简称写法与历史数据保持一致,避免迁移;`normalize_region` 会把「内蒙古自治区」等归一。
|
||||||
|
- **Excel 导出**:`app/export.py` 通用 `build_xlsx()`(表头样式/冻结首行/自动筛选/列宽自适应/金额千分位),`app/routers/export.py` 提供 4 个端点,前端 `download()` 触发。中文文件名用 RFC 5987 编码。
|
||||||
|
- **SQLAlchemy 加字段的坑**:`Base.metadata.create_all()` 只建新表,**不会给已有表加列**。必须显式 `inspect(engine).get_columns()` 判断后 `ALTER TABLE ... ADD COLUMN`。
|
||||||
|
|
||||||
|
## 热加载 + 项目管理改名/操作列可见 + 抽屉周期切换(2026-09-13)
|
||||||
|
|
||||||
|
- **热加载**:`run.sh` 的 DEV 分支加 `--reload-include "*.js" "*.css" "*.html" ".env"`(前端和 .env 改动也重启);`app/config.py` 加 `DEV`,`app/main.py` 生成 `BOOT_ID` 并由 `/health` 返回 `{dev, boot}`;新增 `web/livereload.js` 轮询 `/health`,`boot` 变了就 `location.reload()`。
|
||||||
|
- **坑 1**:macOS 自带 bash 3.2 下空数组 + `set -u` 会 unbound variable,展开要写 `${RELOAD_ARGS[@]+"${RELOAD_ARGS[@]}"}`。
|
||||||
|
- **坑 2**:`--reload-include` 是按 glob 匹配的,`data/*.db` 不在列表里,所以写库不会触发重启(否则自己把自己刷死)。livereload 只在 `boot` 变化时刷新,轮询本身不会死循环。
|
||||||
|
- **项目库 → 项目管理**:导航、卡片标题、导出表名/文件名、README 全部改名(`app/routers/export.py` 的 sheet 名和文件名一起)。
|
||||||
|
- **「更名/归档/删除 看不到」的原因不是功能缺失**,是表格总宽超出容器后被横向截断,最右的操作列掉到可视区外。修法是两手:① 收紧各列(分类 92 / 负责人 76 / 状态 84 / 进度 56 / 标签 96,名称 min-width 170)+ 操作列按钮 `padding: 0 7px`,1280 宽下 `scrollWidth - clientWidth == 0`;② 操作列直接复用已有的 `col-ops` sticky 类(`position: sticky; right: 0`),窗口窄的时候按钮始终吸在右边。
|
||||||
|
- **坑 3**:`border-collapse: collapse` 时 `position: sticky` 的 `td` 不生效,必须 `border-collapse: separate; border-spacing: 0`。
|
||||||
|
- 一开始另写了个 `.col-act` 类和 `.col-ops` 一模一样,属于重复,已合并回 `col-ops`。
|
||||||
|
- **详情抽屉的周期反馈改成可切周期**:`state.dw = {group, range, back}`,`feedUnits()` 按周/按月归组,`FEED_RANGES` 给两套范围(周:本周/近4/近12/全部;月:本月/近3/近6/全部),`‹ ›` 按 `range` 步进翻页,到边界自动 disabled。锚点取顶栏选中周期所在单元,所以抽屉和看板顶部口径一致。
|
||||||
|
- `renderFeed()` 自己 `$("#dwFeed")` 取宿主节点,切组/切范围/翻页只重渲染这一块,不重新请求接口。
|
||||||
|
- **坑 4**:`d.timeline` 后端已按 `sort_key` 升序,归组时直接 `map`/`forEach` 保持顺序即可,不要再 `reverse()`(历史进度走势那一块才需要倒序)。
|
||||||
|
- 「更早还有 7 月」会被读成「7 月份」,月的量词统一用「个月」。
|
||||||
|
|
||||||
|
## 项目管理列表页去掉标签列(2026-09-13 下午)
|
||||||
|
|
||||||
|
- 需求「列表页不显示标签字段」按**祈使句**理解 = 列表页不要标签列(不是「标签没显示出来」的 bug 报告)。依据:`tags`、`project_tags` 两张表都是 0 行,全库没有标签数据,那一列在 5 行里全是空输入框 + 被截断的 placeholder,只是白占名称列宽度。
|
||||||
|
- 改动(`web/app.js`):删行内 `<td>` 的 tags 单元格、删表头 `<th>标签</th>`,并连带删掉因此不可达的 `else if (field === "tags")` 保存分支(自己产生的孤儿代码)。抽屉里的标签编辑 `#dwTags` 和周期看板卡片上的标签胶囊**保留**,用户只说了列表页。
|
||||||
|
- 回归验证:表头 `[项目名称, 分类, 负责人, 状态, 进度%, 最近更新, 操作]`、`scrollWidth-clientWidth == 0`、操作列仍 sticky;`[data-f]` 只剩 `category_id/owner/status/progress`。
|
||||||
|
- **坑**:`locator.fill()` 只写 DOM value,**不触发 change 事件**,PATCH 根本没发出去,会误判成「保存功能坏了」。要验证行内保存必须走真实键盘:AX `click` 聚焦 → `typeText` → `pressKey Tab`。测完 toast「已保存」、副本库 owner 更新、真实库 owner 与 tasks/updates 计数(329/454)前后一致。
|
||||||
|
- 写操作用 `DATABASE_URL=sqlite:////tmp/copy.db`(`config.py` 的 `_load_dotenv` 用 `setdefault`,外部 export 优先)起独立实例,绝不连真实库。
|
||||||
|
- 另:`run.sh ... | tail -30` 会把 uvicorn 日志憋到进程退出才吐,排障时别接 tail。
|
||||||
|
- **发现 8770 上的服务已停止**(原 PID 17513 不在、端口无监听,不确定是用户自己停的还是上一轮误杀),已用 `NO_OPEN=1 nohup ./run.sh` 重启,`/health` 返回 `dev:false`(不带 --reload)。
|
||||||
|
|
||||||
|
## 「8770 还是老系统」排查(2026-09-13 下午)
|
||||||
|
|
||||||
|
- 结论:**不是服务端跑旧代码**。`sha256(web/*)` 与 `curl /static/*` 返回的内容逐个比对全部一致(app.js bd9b07b1…、styles.css 817480ff…、index.html a983b5af…)。前端零构建、`StaticFiles` 每次请求现读磁盘,所以**即使有旧 Python 进程在跑,界面也只由 `web/` 目录决定**——界面旧必然是客户端问题。
|
||||||
|
- 两个叠加原因:
|
||||||
|
1. 用户 `./run.sh` 其实没启动成功:8770 被我(Codex)起的实例占着,run.sh 的端口探测命中后打印「端口 8770 上已有服务在运行」就 `exit 0`,看起来像启动成功了。**教训:不要长期占着用户的服务端口**,最后 `launchctl remove com.unis.board` 交还端口,只留一个普通 uvicorn(非 launchd)方便 `lsof -ti:8770 | xargs kill`。
|
||||||
|
2. 浏览器缓存:`/`、`/static/*` 之前只有 ETag/Last-Modified,**没有 Cache-Control**,Chrome 走启发式新鲜度缓存,可能长时间不回源;SPA 又只在加载时读一次 app.js。
|
||||||
|
- 修法(`app/main.py`):加 `no_cache_frontend` 中间件,对 `/` 和 `/static/*` 发 `Cache-Control: no-cache`。验证:四个静态资源响应头都带 no-cache,带 `If-None-Match` 复访返回 **304**(本地零开销);`/api/*` 不受影响。
|
||||||
|
- **坑**:Starlette 1.6.0(fastapi 0.141.1)的 `StaticFiles.__init__` **已经没有 `headers` 参数**了(`(directory, packages, html, check_dir, follow_symlink)`),传 `headers=` 直接 TypeError。要加响应头只能上中间件。
|
||||||
|
- 已缓存的旧副本仍需用户**硬刷一次**(Cmd+Shift+R)强制回源,之后才自动走 no-cache。
|
||||||
|
|
||||||
|
## 列表只留「修改」+ 更名收进详情面板 + 周期反馈倒序(2026-09-13 傍晚)
|
||||||
|
|
||||||
|
- 需求里的「修改」按钮**原本不存在**(列表上是 详情/更名/归档/删除 和 改名/归档/删除)。按「每行只留 1 个按钮,标签为『修改』,点了开详情面板」实现;改名/归档/删除两个抽屉里本来就有,功能不减。定制交付的「列表」= 项目明细 tab(阶段看板/收入分析不动)。
|
||||||
|
- `web/app.js`:
|
||||||
|
- 两处操作单元格压成一个按钮(`data-open` / `data-copen`,处理器原本就有);删掉随之失效的 6 个行内处理器(`[data-rename] [data-arch] [data-delp] [data-crename] [data-carch] [data-cdelp]`)和只被它们调用的 `findCustomRow()`(孤儿)。`openRenameCustomDialog` → 通用 `openRenameDialog(url, name, onDone)`,两处调用传各自的 PATCH 地址。项目管理抽屉的 `#dwRename` 原来是 `prompt()`,跟全站风格不一致,一并改成走同一个 modal。
|
||||||
|
- **坑**:参数改名时漏了模板里的 `value="${esc(name)}"`,而浏览器里 `name` 是全局 `window.name`,**不会报 ReferenceError**,只会静默渲染成空输入框。`node --check` 也查不出来。改全局作用域里的变量名要专门 grep 一遍裸 `name`。
|
||||||
|
- **周期反馈倒序**:只在展示层 `win.slice().reverse()` + 月组内 `u.periods.slice().reverse()`。**不能反转 `feedUnits()` 的返回值**——`anchor/first/last/back/step` 全依赖升序下标,反了以后 ‹ › 翻页和「更早还有 N 周」会全错。实测:近 4 周显示 8月第4周→8月第1周,点 ‹ 变 7月第4周→6月第4周 且 meta 从「更早还有 8 周」变「4 周」,语义不变。
|
||||||
|
- 验证仍走 `DATABASE_URL=sqlite:////tmp/copy.db` 的副本实例(8801):项目管理/定制明细操作列均只剩 `["修改"]` 且 sticky;两条更名链路(含重名报错「已存在同名项目…请换一个名称」保留在 modal 内、确定按钮重新可用)都通过;真实库未动。
|
||||||
|
|
||||||
|
## 列表彻底只读 + 详情/修改双模式(2026-09-13 傍晚第二轮)
|
||||||
|
|
||||||
|
- 需求「列表保留 详情/修改/归档、详情面板不改基本信息、基础信息按钮与附表更新信息要区分、删除要逐字输入项目名」。**上一轮刚把操作列压成一个「修改」,这一轮又拆回三个**——需求在反复,别再往一个方向优化,改成 `openDrawer(pid, mode)` / `openCustomDrawer(cid, mode)` 双档后,按钮数量怎么变都只是模板里几行。
|
||||||
|
- 改动(只在 `web/app.js`、`web/styles.css`,Python 一行没动):
|
||||||
|
- 两张列表彻底去行内编辑:`renderProjects` 删 `[data-pf]` 单元格与 change 处理器,负责人/状态/进度改 `esc()`/`statusPill()`/`NN%`;`renderCustomList` 删 `[data-cf]` 与 `bindCustomListEdit` 里的 PATCH 块,函数更名 `bindCustomList`(调用点同步),金额列统一 `fen()` 并在表头标 `/万`,`stagePill`/日期纯文本。两处星标改 `class="star readonly"` 去掉 `data-star`(快速切重点没了,抽屉里有按钮)。
|
||||||
|
- 抽屉双档:`mode` 只影响「基础信息」区块——view 出 `.ro` 只读行 + `.sec-actions`「基础信息 · 操作」(修改/重点/归档/删除),edit 出表单 + `.sec-actions`「基础信息 · 编辑」(只有保存/取消 + `#dwErr`/`#cdErr` 行内报错)。附表(周期反馈、历史走势、周期执行记录、阶段流转、收入确认计算值)一律挂 `<span class="sec-kind ro">…只读</span>`,和基础信息的操作条在视觉上分开。
|
||||||
|
- 改名从独立弹窗并入「修改」表单首行(`#dwName`/`#cdName`,后端两个 PATCH 本来就收 name 且做非空+重名校验),`openRenameDialog` 变成孤儿已删。保存失败写 `#dwErr` 并解禁按钮,成功后回 view 档并 `renderProjects()`。
|
||||||
|
- 删除:`confirmDialog({danger, okText:"永久删除", requireText: p.name})`,`#mOk` 初始 disabled,`#mConfirmText` 的 input 事件里 `trim() !== requireText` 就继续禁用。定制与项目管理两处都走这条。
|
||||||
|
- **两个自己发现的设计破绽(已修)**:
|
||||||
|
1. 定制抽屉「收入确认」区块在 edit 档仍标着「计算值 · 只读」,却渲染了开票/回款输入框 → 徽标改成随 mode 切换(`开票与回款可编辑`),两个输入框包进 `.sec-actions` 并补一句「随上方「保存」一并提交」(保存按钮在基础信息那条操作条里,不然用户不知道点哪提交)。
|
||||||
|
2. `<div class="form-row"><label>进度</label><input/> %</div>` 里裸的 `%` 是**第三个 grid item**,`.form-row` 只有两列,`%` 会掉到下一行。包一层 `<div style="display:flex">` 解决(`#dwProgress`、`#cdProgress` 各一处)。
|
||||||
|
- **严重事故(上一轮):`src.index(marker)` 静默删掉 174 行**。用 `src[a:b]` 做区间替换时锚点 `b` 取的是「下一个 marker 的位置」,而那个 marker 在文件里出现在预期之外的位置,于是中间 174 行被整段吞掉。**`node --check` 依然通过**(因为吞掉的是一整个完整函数),语法检查完全查不出来。
|
||||||
|
- 教训:非 git 仓库里做区间替换,改完必须**比对函数名清单**(`grep -n "function " old new` 做集合差),不能只看语法;优先用「唯一锚点整串替换 + `assert src.count(old)==1`」,别用 index 区间。
|
||||||
|
- **并发写入风险(本轮实测)**:`web/app.js` 在被本任务修改期间出现 `14:02 → 14:04 → 14:05 → 14:06` 的整文件重写,说明另一个会话也在改同一个文件(不是 git 仓库,没有回滚手段)。开工前 `stat` 连测 45s 确认 mtime 不动了才动手;接手时务必先 `stat` + `grep` 特征标记(`data-pedit`/`data-cedit` 应存在、`openRenameDialog` 应为 0)。
|
||||||
|
- 验证(副本实例 8801 + `DATABASE_URL=sqlite:////tmp/copy.db`,真实库未动):两张表操作列集合分别 = `{详情/修改/归档}`、归档行 = `{详情/修改/恢复}`;`tbody` 内 `input,select,textarea` 计数 = 0;详情抽屉可编辑控件 = 0;改名为已存在名称 → `#dwErr`「保存失败:已存在同名项目「AI Agent」,请换一个名称」且按钮解禁;真实改名往返成功(列表与抽屉标题同步);删除弹窗输入前缀「NEX云桌面」按钮仍 disabled、输入全名才解禁;侧栏分类/只看重点/含归档/全局搜索、7 个视图逐个点开均正常,`tab.dev.logs()` 空。
|
||||||
|
|
||||||
|
## 列表/详情职责边界 + 附表写入分流(2026-09-13 傍晚第三轮)
|
||||||
|
|
||||||
|
- 需求四条:① 列表只留 详情/修改/归档;② 详情≠修改(详情面板不改基本信息,外层也不给直接改);③ 详情面板里基础信息的按钮与附表更新信息要区分清楚;④ 删除必须逐字输入项目名。接手时 ①②④ 已由上一轮做完,本轮补 ③ 和两处「外层还能直接写数据」的漏网。
|
||||||
|
- **边界判断(都记下来,下次别重新猜)**:
|
||||||
|
- 「项目」= 项目管理 + 定制交付两张表。**每周重点工作的条目不是项目**,它的删除仍用普通危险确认,不要求逐字输入名称(条目名短且可重建,逐个敲全名太折磨人);用户如果要统一,再改。
|
||||||
|
- **工作看板右栏的环节日期 / 开票 / 回款行内快填保留**。那是「本周快录」区,不是项目详情面板,砍掉会让每周例行录入多跳三次页。用户的原话是「详情面板不做基本信息的修改」,范围限定在详情面板。
|
||||||
|
- 附表(周期反馈 / 交付记录)**写入统一走「更新录入」**,不在抽屉里复刻一套录入表单:录入页有「粘贴原文 → AI 整理 → 确认写入」的整链路,抽屉里再造一个简版表单必然出现两条口径不一致的写入路径。
|
||||||
|
- 「+ 录一笔」落在**顶栏当前选中的那一周**(`state.weekId`),和看板其余部分同一个锚点,避免抽屉里选 A 周、顶栏显示 B 周。
|
||||||
|
- 本轮改动(`web/app.js` + `web/styles.css` + README,Python 没动):
|
||||||
|
- **定制交付阶段看板卡片的 ★ 曾直接 `PATCH /custom/projects/{id} {is_key}`**,是「外层不再直接写数据」的漏网。改成 `class="star readonly"` + `title="重点标记在详情面板中维护"`,删掉 `data-star` 处理器和 kcard 点击里的 `if (e.target.dataset.star) return;` 守卫;重点标记只剩抽屉里那一个按钮。
|
||||||
|
- `.sec-actions` 加两个变体:**`.base`**(蓝实线左边框)= 基础信息,两条 edit 条各加 `.sec-hint` 明确「只改基础信息,不会动下面的周期记录」;**`.upd`**(绿底 + 绿虚线边框)= 附表 · 更新记录,放「+ 录一笔「2026年9月第1周」执行记录 / 交付记录」,handler 里 `state.intakeType` + `state.intakePeriodId` + `intakeProjectId`/`customIntakeId` → `closeDrawer()` → `setView("intake")`。
|
||||||
|
- 周期反馈的范围切换标签从「查看范围」改成「**查看范围(只读筛选)**」,和动作按钮区分开,避免被当成一种写操作。
|
||||||
|
- **本轮最大的坑:补丁脚本静默 no-op,还打印成功。** 上一轮的 `patch2.py` 写了 `new = old + '''…样式…'''` 但**漏了 `src = new`**,最后 `f.write(src)` 把原文原样写回,脚本照样 print「styles.css patched ok」。结果 `.sec-actions.base/.upd`、`.sec-hint` 三条规则全都不存在,界面能跑但两类操作条**完全没有颜色区分**(即需求 ③ 事实上没生效),而上一轮的自述里写着「已追加」。
|
||||||
|
- 发现方式极便宜:`diff /tmp/um_bak2/styles.css web/styles.css` 是空的,而 mtime 却是补丁时间。
|
||||||
|
- 教训:补丁脚本必须 `assert out != src` + 写完后 **grep 特征串复检**,绝不能信脚本自己 print 的成功;改完先看 diff 再看语法。同类 bug 在 `patch1.py` 里没出现,是因为它用 `rep()` 里 `global src; src = src.replace(...)`,写 src 反而是对的 —— 说明「看脚本最后一行写了什么变量」不够,得看**产物**。
|
||||||
|
- **CUA 浏览器沙箱的硬限制(本轮全部踩过)**:`tab.playwright.evaluate` 里 **`el.click()` 不是函数、`MouseEvent` 不是构造器**、没有 `fetch`、拿不到页面闭包里的 `state`。点击只能走 `tab.playwright.locator(sel).click()` / `getByRole(...).click()`(注意 `getByRole`/`locator` 挂在 `tab.playwright` 上,`tab` 本身没有)。
|
||||||
|
- strict mode 会因多匹配直接超时:`getByRole('button',{name:'定制交付'})` 命中导航/侧栏分类/录入页签 3 个。用属性选择器收窄,如 `nav [data-view="custom"]`、`#view-custom [data-ctab="list"]`。`locator(sel, {hasText})` 在这层封装里**不生效**。
|
||||||
|
- `#view-custom` 有 4 个页签,默认「交付流水线」,`#view-custom tbody tr` 第一个表是**卡点项目表**(操作列只有「处理」);要测项目明细必须先点 `[data-ctab="list"]`,否则会误报「定制列表操作列丢了」。
|
||||||
|
- `[id^=view-]` 元素的 `offsetParent` **永远非 null**(全部报 VISIBLE),判断当前视图别看这个。看 `select` 的选中项 / `.chip.active` 更可靠。
|
||||||
|
- `tab.getScreenshot()` 返回 Uint8Array,直接 `nodeRepl.emitImage(...)`;`JSON.stringify` 会喷出数万 token。
|
||||||
|
- 实测(副本实例 8801 + `/tmp/copy.db`,真实库未动;`tab.dev.logs()` 空):两张列表操作列 = `[详情, 修改, 归档]`、`tbody` 内 input/select/textarea = 0;详情抽屉可编辑控件 = 0,修改档 6 个控件 + hint;两条操作条 computedStyle 分别是 `3px solid rgb(37,99,235)` 与 `3px solid rgb(22,163,74)`(绿虚线底);删除闸门:`""`/`NEX`/`NEX云桌` → disabled,`NEX云桌面` 与首尾带空格的同名 → enabled(`trim()` 后比对);两处「+ 录一笔」都正确落到更新录入并预选项目与周期(常规 → `intakeProject`/`intakePeriod`,定制 → `cInProject`/`cInPeriod` 且页签切到「定制交付项目」)。
|
||||||
|
|
||||||
|
## 四模块收尾:台账残留退役 + README 按新结构重写(9/13 下午)
|
||||||
|
|
||||||
|
- 上一轮把功能做完了,**README 还停在「平台产品类 / 周看板」的旧结构**,本轮唯一硬缺口就是文档;顺手清掉旧结构残留的「项目台账」。
|
||||||
|
- **台账退役的判断顺序**:先 `sqlite3 data/board.db "select count(*) from ledger_items"` → **0 行**,确认删界面无数据损失,才动代码。删的只有「暴露出去的那层」:`GET /api/ledger` 函数、`/api/meta` 里的 `ledger_categories`、`index.html` 的 `#view-ledger`、`app.js` 的 `renderLedger`/`state.ledger`/`refresh()` 分支。**保留** `LedgerItem` 模型 + `constants.LEDGER_CATEGORIES` + `scripts/import_excel.py` 的台账解析(导入链路是历史 Excel 的完整还原,删它属于扩大范围;要不要连导入一起退役,留给用户拍)。改完 `/api/ledger` 在 8770/8802 都返回 404。
|
||||||
|
- **README 重写的取舍**:开篇「两套体系」表改成「四模块 + 三支撑页」表;`## 功能` 拆成 `### 1. 工作看板` / `### 2. 项目管理` / `### 3. 定制交付` / `### 4. 每周重点工作`;原「周看板」小节升格成「工作看板」并补第三组清单的来源;原「执行面板」不再单列模块,写成看板底部「统计概览」折叠区(`<details class="ov">` + `GET /api/stats`)。数据模型加 `WeeklyFocus`,并给 `LedgerItem` 标注「只剩模型与导入解析,界面无入口、接口已下线」;API 速览补 `# 每周重点工作` 段、`/api/export/focus`、`/api/categories`,把 `/api/meta` 的说明从「状态字典 / 分类」改成「状态字典 / 优先级 / AI 配置」(**`/api/meta` 从来不含分类**,侧栏分类来自 `GET /api/categories`;`/api/board` 页面主链路已不调用)。
|
||||||
|
- **本轮自己的坑:列表切片赋值得到 1804 行。** `L[a:b+1] = board` 里 `board` 是**字符串**,Python 会把切片赋值的右值当可迭代对象**逐字符展开**,README 从 344 行炸到 1804 行且 `assert out != src` 照样通过。改成 `board.split("\n")` 才对。教训:切片赋值右值必须是 list;补丁脚本除了 `assert out != src`,还要 **断言行数量级**(`344 -> 384` 一眼合理),否则 diff 阶段才发现就晚了。
|
||||||
|
- **验证(副本实例 8802 + `/tmp/audit.db`,真实库 mtime 全程未变)**:`node --check` 通过;`grep -o 'function [A-Za-z0-9_$]*'` 多重集 diff 唯一差异是 `renderLedger`(预期删除),无新增孤儿;`py_compile` 通过;`/api/meta` 键 = `statuses/priorities/ai`(无 `ledger_categories`);8770 与 8802 的 `/static/{app.js,styles.css,index.html,livereload.js}` sha1 与磁盘一致;浏览器七视图(board/projects/custom/focus/staff/intake/settings)全部渲染、`tab.dev.logs()` 为空;看板三组 = `重点项目 3 / 定制项目 6 / 每周重点工作 2(1 条逾期)`,KPI 含「本周重点工作 1 · 逾期 1 · 已完成 1」;项目列表行操作列 = `[详情, 修改, 归档]` 且 `tbody` 内控件数 0;详情抽屉 view 档控件 0、两条操作条 `.sec-actions.base`(基础信息·操作)与 `.sec-actions.upd`(附表·更新记录);删除闸门 `initial/partial → disabled`、逐字全名 → `enabled`,取消后未删。
|
||||||
|
|
||||||
|
|
||||||
|
## 每周重点工作改独立录入面板 + 面板动作条分上下(2026-09-13 傍晚第四轮)
|
||||||
|
|
||||||
|
- 需求:① 周重点工作的新增改**单独面板**(列表顶部那张内嵌卡撤掉);② 预计完成时间录成「**1周 / 2周**」这种相对周;③ 提供**删除**;④ **所有基础信息相关的按钮放面板上方,更新相关的按钮放列表附表上方**。
|
||||||
|
- **周口径必须复用既有函数,别再手写一套**。`app/constants.py:period_first_day(y,m,w)` 定义「该月第一个周一为第 1 周」,「入库时间」已经是这个口径。第一版自己在 `focus.py` 里写了个 `_first_monday` 推导,跨月周次的第 5 周算歪,第二轮删掉它改成 `date.fromisoformat(constants.period_first_day(...))` 推日期再回算目标周的 `week`,前后端才一致(前端 `firstMondayDay(y,m)` 是它的镜像,只用来把「3周」显示成「2026年10月第1周」,落库换算以后端为准)。核对样本:2026-08 首个周一=03 ⇒ 8月第4周=08-24(与 README 一致)、09=07、2027-01=04。
|
||||||
|
- **相对周换算放后端**(`_resolve_period`):`weeks` + `base_period_id` → 目标周;目标周在 `periods` 里不存在就**自动建周**(label `"{m}月第{w}周"`,`sort_key = y*10000+m*100+w`),否则用户选到跨月的第 5 周会直接 400。`period_id` 从必填改成可空并保留旧用法;`weeks`/`period_id` 都不给 → 400,`weeks` 越界 → 422(`Field(ge=1, le=52)`)。PATCH 时前端只在**下拉改了**才发 `weeks`(编辑档首项 `不改(当前 …)` 的 value 是空串),否则改个错别字会把预计完成周悄悄挪走。
|
||||||
|
- **动作条上下分层的落地方式**:把原来嵌在「基础信息」section 里的 `sec-actions base` 整块**摘出来**当 `baseBar`,前置到 `#drawerBody` 的第一个子元素,加 `.top` 让它排在 `#drawerBody` 第一个子元素。我先做成了 `position:sticky;top:0` + 通栏负 margin + 阴影(吸顶常驻),**用户看完要求「按钮面板不要悬浮,嵌入到面板中」**,最终形态就是 `.sec-actions.top { margin: 0 0 14px; }` —— 继承 `.sec-actions` 的卡片样式(浅底 + 圆角 + 1px 边框 + 蓝色左边框)当普通块放在面板最上方。别再自作主张加 sticky/负 margin/阴影:面板内的动作条属于内容,不该漂在滚动容器上。`sec-actions.upd` 原样留在附表 section 标题下方,不动。项目抽屉 / 定制抽屉 / 重点工作抽屉三处同一套。
|
||||||
|
- **删除的确认强度按对象分档**(沿用上一轮定的边界):重点工作条目 = 普通危险确认,**不要求逐字输入**;逐字输入项目全名只用在项目管理与定制交付的项目删除上。列表行与抽屉顶部共用同一个 `deleteFocus()`,删完刷新计数、抽屉开着就一起关掉。
|
||||||
|
- **补丁脚本又踩两次同一个坑**:
|
||||||
|
- 剪一段代码的 helper 写 `src[:i] + new + src[j+len(end):]` 把**结束锚点一起吃掉了**(`</div>` 少了半个),得写 `src[:i] + new + src[j:]` 保留锚点。
|
||||||
|
- 结束锚点 ` const body = shown.length ?` 在文件里**不唯一**,唯一性断言只对起始锚点做就会漏。要么加长锚点,要么对两端都 assert。
|
||||||
|
- 验证(副本实例 8803 + `/tmp/audit2.db`,**8770 真实库 mtime 全程未动**):`weeks=1/2/3` 分别落到本周边/跨月第5周且自动建周;不给时间 400、`weeks=99` 422、老 `period_id` 仍可用;`node --check` + `py_compile` 通过;函数名多重集 diff = +4(`firstMondayDay`/`weekShiftKey`/`dueWeeksHtml`/`deleteFocus`)−1(`weekSelectHtml` 成孤儿后删除);`sec-actions base top` 全库 6 处;浏览器侧列表内嵌卡已消失、行操作 `[详情,修改,归档,删除]`、新增面板下拉 `1周 · 2026年9月第3周 … 12周`、三个抽屉的首个子元素均为 `sec-actions base top` 且 `position:sticky`、view 档可编辑控件 0、`tab.dev.logs()` 为空。
|
||||||
|
- **待用户拍**(两轮未答,别自己决定):`scripts/import_excel.py` 里台账 `LedgerItem` 的导入解析要不要一起退役。
|
||||||
|
|
||||||
|
|
||||||
|
## 抽屉 Title Bar 改「标准按钮 Bar」(2026-09-13 傍晚第五轮)
|
||||||
|
|
||||||
|
- 需求原话:**「Title Bar 重新设计,不要采用当前的方式。就采用标准的按钮 Bar 模式。」** Default 模式下 `request_user_input` 不可用,按最大可能性解释并**在交付时明确声明了这个假设**:改的是**详情抽屉的标题栏**(`.drawer-head`),页面顶部那条周期栏(周/月、周期下拉、搜索、更新录入)没动,已在回复里说明「如指顶部周期栏可继续」。
|
||||||
|
- **别再自造「基础信息 · 操作」这种带标签的动作条了**。上一轮把动作条从 sticky 改成嵌在正文最上方,用户还是不满意 —— 他要的是**标准对话框按钮栏**:标题在左、动作按钮成排在右、✕ 最右,主按钮在最右、危险动作在最左。正文里那条自造的动作条整块删掉。用户这几轮的收敛方向是一致的:**去装饰、少自造、往通用 UI 约定靠**(去标签列、按钮别悬浮、按钮栏要标准)。
|
||||||
|
- **落地方式(三个抽屉同一套)**:`index.html` 抽屉头加 `<div class="btn-bar" id="drawerActions"></div>`(在 ✕ 之前)+ 标题包一层 `.drawer-head-text{flex:1;min-width:0}`;`app.js` 加 `drawerActions(html)` 一个 helper,`openFocusDrawer`/`openCustomDrawer`/`openDrawer` 各自把原来的 `topBar`/`baseBar` 变量改成 `drawerActions(...)` 调用;`closeDrawer()` 与三个抽屉的「加载中」处都清空按钮条(不清会残留上一个抽屉的按钮和它的闭包 pid)。**事件绑定一行都不用改**:`$("#dwEdit")` 之类是全局查表,按钮搬到标题栏照样命中。
|
||||||
|
- 跟着搬的两处:**报错提示**从动作条里挪到 `#drawerBody` 第一个子元素(`<div class="notice err" id="dwErr|cdErr|fdErr" style="display:none">`,只在 edit/create 档渲染,JS 里 `err.textContent=…/style.display=""` 原样可用);**「只改基础信息,不会动下面的周期记录」这类 hint** 挪进它自己的 `.sec-title` 下面一行。按钮文字去掉对象名后缀(`删除项目/删除条目 → 删除`、`恢复项目/恢复条目 → 恢复`),标题栏已经写了对象名。定制抽屉那条「随上方「保存」一并提交」改成「随**标题栏**「保存」一并提交」—— 按钮搬家,指路的文案必须跟着搬。
|
||||||
|
- **被本次改动变成孤儿的 CSS 要清**:`.sec-actions.base`、`.sec-actions.top`、`.sec-actions .notice` 三条全删(`.sec-actions` 与 `.sec-actions.upd` 留着,附表的「+ 录一笔」还在用)。`.drawer-head` 改 `align-items:center`,`h3` 与 `.drawer-sub` 都加 `nowrap+overflow:hidden;text-overflow:ellipsis`,标题栏才不会被「★ 青岛中车四方园区治安道路项目」这种长名撑成三行。
|
||||||
|
- **⚠️ 同一 workspace 有两个会话在写文件**:本轮开工时 `web/app.js` 在 15:45:49 / 15:46:14 / 15:46:51 被**另一个 agent** 连续改写(3050 → 3153 行,锚点行号整段漂移)。教训:**动手前先轮询 `shasum` 到连续 60s 不变**,再 `cp` 一份新备份(`/tmp/um_bak6/`),补丁脚本一律「唯一锚点 + `assert count==1` + `assert out!=src`」,改完 `node --check` + grep 特征串复检。绝不要拿着几分钟前读到的行号去改。
|
||||||
|
- 验证(**全程只在 8770 只读浏览,点的全是取消/关闭,真实库一条没动**):`weekly_focus=1 / max(periods.id)=50 / projects=82 / custom_projects=119` 与开工前一致,`/health` 200。浏览器实测:项目 view 档标题栏按钮 `[删除,归档,取消重点,修改]` 且正文 0 个可编辑控件、正文仅剩 `.sec-actions.upd`;edit 档 `[取消,保存]`、正文首元素 `notice err (display:none)`、hint 已进 section;定制 view `[删除,归档,标记重点,修改]` / edit `[取消,保存]`;重点工作 create `[取消,添加]`、view `[删除,归档,修改]`;`.drawer-head` `align-items:center`、标题 ellipsis、`drawer-head` 高 73px(标题 1 行 + 副标题 1 行);`tab.dev.logs()` 为空。
|
||||||
|
|
@ -0,0 +1,106 @@
|
||||||
|
# 2026-09-13 周期错位修复
|
||||||
|
|
||||||
|
## 问题
|
||||||
|
系统里「2026年8月第4周」混进了「9月第1周」的内容(两块录到同一周期)。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
原始 Excel《周工作情况总结》最后一次导入时,表尾**只有一个「8月第4周」标签**,其内容里连着贴了两段。
|
||||||
|
`scripts/import_excel.py` 按 (项目, 子任务, 周期) 去重,同周期内重复出现就 append 到同一条记录
|
||||||
|
(见 import_excel.py:170-197),于是两周内容被揉在一起。Excel 文件后来(2026-09-13 16:08)被拆成了
|
||||||
|
「8月第4周」(行838-862) 和「9月第1周」(行863-890) 两个块。
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
脚本 `scripts/fix_period_split.py`(--dry-run 预演,默认执行,自动备份 data/board.db)
|
||||||
|
按 Excel 现状把数据库对齐:
|
||||||
|
- 2026年8月第4周 → 只留 8月第4周内容(9 常规 + 1 定制)
|
||||||
|
- 2026年9月第1周 ← 从 8月第4周拆出的 9月第1周内容(10 常规 + 1 定制「金昌视综平台定制开发」)
|
||||||
|
- 2026年9月第2周 ← 原误放在 9月第1周 的数据(用户手工录入的 NEX 云桌面 2 条)
|
||||||
|
- 顺带重算受影响项目的进度/状态、受影响定制项目的入库时间
|
||||||
|
|
||||||
|
## 全量核查结论(内容级比对 Excel vs DB)
|
||||||
|
- 无跨周污染残留(8月第4周/9月第1周 那个是唯一一起,已修)
|
||||||
|
- 另有 7 条「DB 内容比 Excel 多」:
|
||||||
|
- 2025年3月第1周 3 条(云桌面产品培训/公司内IT平台规划/云屏V1.2版)
|
||||||
|
- 2026年5月第3周 2 条、5月第4周 1 条、6月第1周 1 条
|
||||||
|
→ **同一周内同一子项在源表里占多行**,导入时合并成一条;非跨周问题
|
||||||
|
- Excel 里存在**同标签重复块**:2024年9月第二周(行143/160)、2025年5月第3周(行503/528),
|
||||||
|
导入必然合并,属源表歧义,未擅自拆分
|
||||||
|
- 「智听云」→ 改名「iMeeting 语音智能体」、「智空无人机平台」→ 合并进「智飞无人机平台」,
|
||||||
|
在全量比对中表现为 Excel/DB 互不匹配,属正常
|
||||||
|
|
||||||
|
## 教训
|
||||||
|
导入脚本对「同周期同子项重复出现」采用 append 合并,源表一旦把两周内容贴在同一标签下就会静默串周。
|
||||||
|
后续若发现某周期记录数明显偏多,优先怀疑源表标签缺失。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 重点项目 / 定制项目:多负责人 + 团队成员
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
重点项目和定制项目都可以指定多个负责人,并新增「团队成员」。
|
||||||
|
|
||||||
|
## 兼容策略(关键决定)
|
||||||
|
- `owner` 列语义不变 = **主负责人** = `owners[0]`。列表、看板、导出、人力负载、KPI 全部照旧读 `owner`,**一行未改**。
|
||||||
|
- **不建关联表**:两张项目表各加 `other_owners`(其他负责人,不含主)与 `members`(团队成员)两个 `String(512)` 列,
|
||||||
|
存**首尾带逗号**的 CSV:`,张三,李四,` 或 NULL。于是 `LIKE '%,张三,%'` 是精确整名匹配,
|
||||||
|
「张三」不会命中「张三丰」(已测)。
|
||||||
|
- 新列靠 `app/schema_patch.py: ensure_columns` 启动时自动 `ALTER TABLE`,**无需迁移脚本**;真实库 `data/board.db` 本次未被写入。
|
||||||
|
- API 字段:`owners`(= 主 + 其他,主在首位)、`members`。PATCH **整体替换**:不传=不改,传 `[]`=清空。
|
||||||
|
老调用方只传 `owner` 时 `other_owners` 保留 → `owners` 自动变成 `[新主, ...其他]`,不会写出脏数据。
|
||||||
|
- 工具函数集中在 `app/models.py`:`split_people / dedupe_people / join_people / people_like / all_owners / set_people`;
|
||||||
|
`set_people` 同时负责「`owners[0]` 回写 `owner`」。
|
||||||
|
|
||||||
|
## 口径(容易误解,已和用户对齐的说法)
|
||||||
|
- 「负责人」筛选(项目列表 / 定制列表 / 周看板)**主负责人和其他负责人都算命中**;**团队成员不参与**该筛选(有意)。
|
||||||
|
- 列表、导出、人力负载、KPI 只按主负责人统计;列表负责人列只显示主负责人。
|
||||||
|
- 人员改名(`routers/staff.py`)会同步改写 `other_owners` / `members` 里的名字。
|
||||||
|
|
||||||
|
## 入口
|
||||||
|
只在详情抽屉的「修改」档(`peopleEditorHtml` / `peopleValues` / `bindDrawerPeople`,增删按钮一次性委托在 `#drawerBody`)。
|
||||||
|
第 0 行:负责人档标「主」不给删;成员档空占位行不给删,有值就能删。列表行仍然只有 详情/修改/归档。
|
||||||
|
**新建弹窗(npOwner/ncOwner)保持单个负责人**——假设:建好后进详情面板补名单。
|
||||||
|
|
||||||
|
## 并发提醒
|
||||||
|
`web/app.js` 同期被另一个任务改(人力资源表的星标计数),期间出现过一次模板串语法错误导致整站 JS 不解析(对方已自行修复)。
|
||||||
|
改这个文件前先 `node --check web/app.js` + `shasum`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 周工作 Focus:只显示当周 + 上周延期结转;录入期数 ≠ 预计完成时间
|
||||||
|
|
||||||
|
## 需求
|
||||||
|
> 周工作Focus的只显示当周和上周延期过来的。录入的时候也要注意这点,选择的结束时间并不是作为录入的期数。
|
||||||
|
|
||||||
|
老逻辑把 `due_date` 反查成 `periods` 里的一周存进 `period_id`,于是本周录一条「两周后交付」的活儿直接掉进「未来」堆,本周看不见。
|
||||||
|
|
||||||
|
## 语义变更(核心)
|
||||||
|
- `WeeklyFocus.period_id` 语义由「预计完成时间所属周」改成**录入周**,和 `Update.period_id`(反馈所属周)一致。
|
||||||
|
字段名/`to_dict` 键名(`period_id / period_label / period_full / sort_key`)全部保留 → 前端与导出零连锁改动。
|
||||||
|
- `due_date` 退化成纯目标日期:`focus.py: _resolve_period()` → `_entry_period(db, period_id)`,
|
||||||
|
**不再按日期反查周期**;POST 传 `period_id`=顶栏当前周,没传(脚本/API 直调)按 `constants.week_of_date(date.today())` 补;
|
||||||
|
PATCH 只有显式传 `period_id` 才搬周,改 `due_date` 不再动归属。
|
||||||
|
- 导出列「所属周」→「录入周」(`export.py`,排序仍按 period.sort_key desc)。
|
||||||
|
|
||||||
|
## 分堆口径(`web/app.js: focusBuckets`)
|
||||||
|
- `week` = 录入周 == 基准周;`overdue` = 录入周 < 基准周 且 `delayed` 且未完成(上周及更早的结转);`done` 仍是交叉视图。
|
||||||
|
- **删掉「未来」堆**(chip 从 5 个变 4 个:本周/延期/已完成/全部);`restoreUI` 里把存量的 `focusFilter==="later"` 复位成 `week`,否则老会话进来会看到空表。
|
||||||
|
- 取舍(已明确告知用户):更早周次录入、**没标延期**又没完成的条目不进当周视图,只能在「全部」里查 → 每周收尾要手动勾「延期」才会结转。
|
||||||
|
- 抽屉:录入周只读一行(新增取顶栏周,编辑显示原周);预计完成时间与录入周在详情里拆成两行;`#fdSave` 只在 `mode==="create"` 时带 `period_id`。
|
||||||
|
- 列表「预计完成时间」下的小字改为「录入 X月第N周」;看板左栏结转条目多挂一个「录入 …」标签。
|
||||||
|
|
||||||
|
## 顺带修的默认值问题(会导致本周看起来是空的)
|
||||||
|
`/api/periods` 里已预建了未来的周(库里已有 9月第3周),而 `loadPeriods` 的默认周取「列表最后一期」→ 顶栏会落在**下周**,
|
||||||
|
新的录入周口径下当周直接空掉。改为 `/periods` 多返回 `today_id`(`constants.week_of_date(date.today())` 命中的周),
|
||||||
|
默认周取 `today_id || 最后一期`。仅在「存的 weekId 不在列表里」时生效,不覆盖用户手选的周。
|
||||||
|
|
||||||
|
## 数据
|
||||||
|
库里 3 条 focus(全是 2026-09-13 建的)`period_id` 本来就是 9月第2周 = 今天所在周 → **无需回填**,语义切换后归属不变(已断言)。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
`/tmp/umtest/t_focus.py`(TestClient + `data/board.db` 副本):due=两周后仍归本周 / 不传 period_id 归今天所在周 /
|
||||||
|
改 due 不搬周 / 显式 period_id 才搬周 / 旧数据不漂移 / 导出 200。
|
||||||
|
UI(8791 + 副本库,插 5 条造结转场景):本周 3(含 due 9-20 那条)· 延期 3(9月第1周×2 + 8月第4周×1,未标延期与已完成的都不进来)·
|
||||||
|
已完成 2 · 全部 8;新增 due=09-27 立即进本周;改成 12-31 仍在本周;切到 9月第3周本周空态文案正确;`dev.logs()` 为空。
|
||||||
|
|
||||||
|
## 待办(历史遗留,未动)
|
||||||
|
`scripts/import_excel.py` 里旧台账 `LedgerItem` 的导入解析是否退役。
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
# 2026-09-14 负责人 / 团队成员:去掉主次 + 横向排列
|
||||||
|
|
||||||
|
## 需求(用户原话)
|
||||||
|
> 负责人、团队成员 不要分主次,横向排列(显示和管理页面)
|
||||||
|
|
||||||
|
采用的解读:① 界面不再出现「主」标记与首行特权,名单里每一位平级、都可删;
|
||||||
|
② 无论显示处(列表列、详情只读)还是管理处(抽屉修改档的人员编辑器),都改成横向一排,不再纵向堆叠。
|
||||||
|
|
||||||
|
## 改动(纯前端,未动后端与库)
|
||||||
|
`web/app.js`
|
||||||
|
- `peopleEditorHtml()`:去掉 `opts.primary`(「主」徽标)与「首行不可删」的特殊分支,
|
||||||
|
每一行都是 `人名下拉 + ×`;「+ 添加负责人 / 添加成员」从名单下方挪进名单容器末尾。
|
||||||
|
- `bindDrawerPeople()`:新增行 `box.insertBefore(row, add)`(原来 appendChild 会把新行甩到按钮后面)。
|
||||||
|
- 删掉 `ownersHtml()`(原「第一位用正文、其余用小标签」),只读处统一用 `nameChips()` 对等小标签;
|
||||||
|
`nameChips(list, empty)` 多一个占位符参数,供卡点/待回款两表保留「未指派」文案。
|
||||||
|
- 4 处负责人列/行改为全部负责人:项目列表、定制「项目明细」、定制「卡点提醒」、定制「待回款排行」。
|
||||||
|
后两处原来只显 `x.p.owner`,现在 `nameChips(x.p.owners, "未指派")`。
|
||||||
|
- 新增 `OWNER_HINT`(两处抽屉共用):界面不标主次,但如实写明「看板、筛选、导出与人力负载取名单第一位做归属统计」。
|
||||||
|
|
||||||
|
`web/styles.css`
|
||||||
|
- `.people-rows`:`flex-direction: column` → `flex-wrap: wrap`(横向一排、放不下自动换行);
|
||||||
|
`.people-row` 改 `inline-flex`;删掉随本次改动失效的 `.people-tag`。
|
||||||
|
- 新增 `.people-cell { display:flex; flex-wrap:nowrap; gap:4px }` 给表格里的负责人列。
|
||||||
|
(先试了 `wrap + max-width:152px`,结果 table 自动布局把列压到 47px、每人一行,等于还是竖排,故改成一排不换行。)
|
||||||
|
|
||||||
|
`README.md`:功能表「负责人与团队」行、负责人下拉小节、数据模型 `owner=主负责人` 措辞、API 备注同步。
|
||||||
|
|
||||||
|
## 口径没动(重要)
|
||||||
|
- 存储照旧:`owner` 列 = `owners[0]`(`models.set_people` 负责回写)。UI 不再暗示主次,
|
||||||
|
但**看板、列表筛选、导出、人力负载、财务「按负责人汇总」仍按那一位归属**——删掉第一位就自动由后一位顶上(已实测)。
|
||||||
|
- 故意没改:新建弹窗的 `npOwner`/`ncOwner` 仍是单人下拉(建完去抽屉加人);
|
||||||
|
周看板卡片与右侧详情头仍是单个负责人 chip(该视图的负责人筛选下拉也按主负责人生成);
|
||||||
|
人力负载表、财务「按负责人汇总」是按人归属的统计表,不是展示位。
|
||||||
|
- 列表仍未加「团队成员」列(保持列表精简,成员在抽屉里看)。
|
||||||
|
|
||||||
|
## 验证(临时实例 8791 + `data/board.db` 副本,未写真实库)
|
||||||
|
- `node --check web/app.js` 通过;`dev.logs()` 全程为空。
|
||||||
|
- 项目列表:AI Agent 4 位负责人一排(`.people-cell` 内 chip 的 top 全等);无负责人显示「—」。
|
||||||
|
- 详情只读:负责人/团队成员首元素即 `.tag-mini`(不再有正文态的第一位),单行。
|
||||||
|
- 修改档:`#dwOwners`/`#cdOwners` 子项 `flex-wrap: wrap`、无 `.people-tag`、4 行都带 `[data-pdel]`、
|
||||||
|
添加按钮在末尾;点添加后空行插在按钮之前。
|
||||||
|
- 删掉第一位(付艾)保存 → `GET /api/projects/83` 得 `owner=潘朝钊`、`owners=[潘朝钊,汤全昆,钟文俊]`。
|
||||||
|
- 定制「项目明细 / 卡点提醒 / 待回款排行」三表负责人列一排显示,空值文案仍分别是「—」和「未指派」。
|
||||||
|
- 收尾:8791 已停(`lsof -iTCP:8791 -sTCP:LISTEN` 空),临时库已删;用户实例 8770(PID 44988/99343)未动,
|
||||||
|
本轮无 .py 改动,浏览器刷新即可看到新界面。
|
||||||
|
|
||||||
|
## 待办(历史遗留,未动)
|
||||||
|
`scripts/import_excel.py` 里旧台账 `LedgerItem` 的导入解析是否退役。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# 2026-09-14 多负责人只对重点项目生效(定制交付改回单负责人)
|
||||||
|
|
||||||
|
## 需求(用户原话)
|
||||||
|
> 多负责人仅对于重点项目适用,请把相关的页面都做调整。
|
||||||
|
|
||||||
|
采用的解读:① 多负责人(`owners` 名单)这套能力**只保留在重点项目**;
|
||||||
|
② 定制交付回到**单个「交付负责人」下拉**,但**上一轮新增的「团队成员」保留**(用户没说要撤);
|
||||||
|
③ 定制侧所有展示位(列表列、只读档、项目明细、卡点提醒、待回款排行)与筛选口径都按单负责人走。
|
||||||
|
|
||||||
|
## 前端 `web/app.js`
|
||||||
|
- 定制抽屉修改档 2044:`#cdOwners` 人员编辑器 → `ownerSelectHtml(p.owner, 'id="cdOwner" style="width:150px"')`
|
||||||
|
单人下拉;2045 的 `peopleEditorHtml("cdMembers", …)` 团队成员编辑器原样保留。
|
||||||
|
- 定制保存 body 2147-2148:`owner: $("#cdOwner").value || null` + `members: peopleValues("cdMembers")`,不再送 `owners`。
|
||||||
|
- 定制只读档 2058-2059:`ro("交付负责人", esc(p.owner||"—"))`(回到纯文本)、`ro("团队成员", nameChips(p.members))`。
|
||||||
|
- 定制三表负责人列改回单人名:项目明细 1831、卡点提醒 1674、待回款 1780。
|
||||||
|
- 顺手清掉本轮造的孤儿:`OWNER_HINT` 常量删除并内联到唯一使用点(重点项目 2775,文案改口径);
|
||||||
|
`nameChips(list)` 去掉 `empty` 参数(上一轮为定制两表加的,现在没人传了)。
|
||||||
|
- 新建弹窗 `ncOwner`(1895)/`npOwner` 本就是单人下拉,未动。
|
||||||
|
- 重点项目侧**一行未改**:列表 1350 `.people-cell`+`nameChips(p.owners)`、抽屉 2775/2780/2789/2790、保存 2943-2944。
|
||||||
|
(`.people-cell` CSS 还有这一处使用,故保留。)
|
||||||
|
|
||||||
|
## 后端(收口理由:界面已不可达的写路径必须一起关掉,否则库里会有两份真相)
|
||||||
|
- `app/routers/custom.py`:`CustomProjectIn.owners` 字段删除;孤儿 import `or_`、`people_like` 清掉;
|
||||||
|
列表筛选 → `q.filter(CustomProject.owner == owner)`;
|
||||||
|
POST `model_dump(exclude={"members"})` + `set_people(cp, None, payload.members)`;
|
||||||
|
PATCH → `set_people(cp, None, payload.get("members"))`。传了 `owners` 也直接被忽略(已实测)。
|
||||||
|
- `app/routers/weekly.py`:124(定制段)→ `if owner and cp.owner != owner`;81(重点项目段)保留 `all_owners(...)`。
|
||||||
|
- `app/models.py`:`CustomProject.to_dict` 删 `"owners"` 键;`other_owners` 列注释 → `# 已停用:定制交付不做多负责人`。
|
||||||
|
**列不删、不加迁移**:`set_people(cp, None, …)` 不写该列,旧值原样躺在库里静默隐藏(已实测改写 owner 后旧值仍在)。
|
||||||
|
- `staff.py` 人名改名同步逻辑未动(对 custom 成为 no-op,但代码仍正确,不顺手重构)。
|
||||||
|
|
||||||
|
## `README.md`
|
||||||
|
功能表「负责人与团队」行改成「多负责人仅重点项目;定制 = 交付负责人(单人)+团队成员」;
|
||||||
|
筛选口径写明「重点项目命中任一负责人、定制按交付负责人命中」;
|
||||||
|
`peopleEditorHtml` 小节注明现在只剩重点项目抽屉在用;数据模型 / API 定制行同步(custom 不再接受 `owners`)。
|
||||||
|
|
||||||
|
## 迁移决策
|
||||||
|
真实库 `data/board.db` 的 `custom_projects` 119 行里 `owner`/`other_owners`/`members` **三列全空** → 无数据可迁,
|
||||||
|
故不做任何迁移脚本,只让界面与 API 停止读写 `other_owners`。
|
||||||
|
|
||||||
|
## 验证(临时实例 8792 + `/tmp/umtest/single.db` = 真实库副本 + 造数据,真实库只读)
|
||||||
|
- `node --check web/app.js`、`py_compile app/models.py app/routers/custom.py app/routers/weekly.py` 通过;`dev.logs()` 全程 `[]`。
|
||||||
|
- API:定制 `#5` 响应**无 `owners` 键**,`owner=陈玉婷`、`members=[潘远银,向上]`;重点 `#83` `owners=[付艾,潘朝钊]` 不变。
|
||||||
|
POST/PATCH 带 `owners` 被忽略;定制筛选「陈玉婷」→`[5]`,重点筛选「潘朝钊」→`[83,80]`;
|
||||||
|
`/weekly`、`/stats`、`/custom/board`、`/custom/stats`、`/staff`、`/focus`、`/export/*` 全 200。
|
||||||
|
注:`GET /api/custom/projects/{id}` 返回 `{project, updates, timeline}` 包壳,`owner` 在 `project` 下。
|
||||||
|
- UI(iab tab 30):定制只读档 labels = …交付负责人|团队成员…,无 `#cdOwners`;修改档 `#cdOwner` value=陈玉婷、
|
||||||
|
`#cdMembers` 2 行+添加按钮;端到端改「陈玉婷→汤全昆」保存 → toast「已保存」→
|
||||||
|
库 `(5,'汤全昆',',况春华,陈航,',',潘远银,向上,')`(other_owners 未被改写)→ 只读档显示正确;
|
||||||
|
筛选「汤全昆」命中、筛选遗留名「况春华」不再命中(符合预期)。
|
||||||
|
- 重点项目回归:列表一排多负责人(AI Agent = 付艾|潘朝钊);只读档 ownerChips/memberChips 各 2 个;
|
||||||
|
修改档 `#dwOwners`=[付艾,潘朝钊]、`#dwMembers`=[梁帅超,罗帮旺]、`.people-rows` 横向。
|
||||||
|
- 收尾:8792 已停、临时库已删;用户实例 8770(PID 520/44988)未动,`.py` 由 run.sh 热重载,前端刷新浏览器即可。
|
||||||
|
|
||||||
|
## 待办(未动,等用户定)
|
||||||
|
1. 周看板卡片与重点项目列表的负责人 chip 仍只显主负责人 `owner`,要不要改成显示全部负责人。
|
||||||
|
2. `scripts/import_excel.py` 旧台账 `LedgerItem` 导入是否退役(历史遗留)。
|
||||||
|
|
@ -0,0 +1,409 @@
|
||||||
|
# 软件开发部 · 关键任务追踪看板
|
||||||
|
|
||||||
|
基于《软件开发部管理工作执行表.xlsx》构建的部门关键任务追踪系统。
|
||||||
|
|
||||||
|
侧栏是**四个业务模块 + 三个支撑页**:
|
||||||
|
|
||||||
|
| 模块 | 适用 | 关心什么 | 数据表 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| **① 工作看板** | 每天进来的第一屏 | 三类工作本周期谁更新了、谁没动,点一条从一览看到详情 | 只读汇总(`/api/weekly` + `/api/focus`) |
|
||||||
|
| **② 项目管理** | NEX云桌面、AI Agent、智飞无人机平台、内部运营… | 长期重点项目的迭代执行、子任务进展、风险阻塞 | `projects` / `tasks` / `updates` |
|
||||||
|
| **③ 定制交付** | 各省市客户定制子项目 | 商务阶段流转(签单→开发→交付→验收→计收)+ 按进度确认收入 | `custom_projects` / `custom_updates` |
|
||||||
|
| **④ 每周重点工作** | 不值得单独建项目档案的短期活儿 | 一条就是一件事,面板里录,归在**录入周**这一周,跟到完成 | `weekly_focus` |
|
||||||
|
|
||||||
|
支撑页三个:**人力资源**(`staff`,全系统「负责人」的唯一来源)、**更新录入**(执行 / 交付记录的唯一写入口:粘贴原文 → AI 整理 → 确认写入)、**设置**(周期管理、AI 配置)。
|
||||||
|
|
||||||
|
②③ 是**两套彼此独立的项目体系**(数据表也完全分开),共用一个**人力资源库**:项目与定制项目的「负责人」都从花名册里选,人员进出只维护一处。④ 的条目不是项目,不挂子任务与执行记录。① 只做汇总与跳转,自己不落数据。
|
||||||
|
|
||||||
|
## 周期管理
|
||||||
|
|
||||||
|
### 新增周期
|
||||||
|
|
||||||
|
三个入口,都会自动接续年份:
|
||||||
|
|
||||||
|
| 入口 | 操作 |
|
||||||
|
|---|---|
|
||||||
|
| 顶部周期下拉 | 选「+ 新建周期…」 |
|
||||||
|
| 更新录入页(常规 / 定制都有) | 周期下拉选「+ 新建周期…」 |
|
||||||
|
| 设置页 → 周期管理 | 「+ 新建周期」按钮 |
|
||||||
|
|
||||||
|
输入格式 `9月第1周`(也可写 `2027年1月第1周` 显式指定年份),年份按以下规则自动推断:
|
||||||
|
|
||||||
|
- 月份 **大于等于** 最新周期的月份 → 用最新周期的年份(9月已有第4周时补录第1周,仍留在同年)
|
||||||
|
- 月份 **小于** 最新周期的月份 → 进入下一年(8月之后建 `1月第1周` → 次年)
|
||||||
|
|
||||||
|
重复创建同一周期会直接返回已存在的那个,不会产生重复数据。
|
||||||
|
|
||||||
|
### 删除周期
|
||||||
|
|
||||||
|
设置页 → 周期管理,表格里列出所有周期及各自的记录数。**只有不含任何执行记录的空周期才能删除**,已有记录的会置灰并说明原因,接口层面也会二次校验。
|
||||||
|
|
||||||
|
### 年份整体平移
|
||||||
|
|
||||||
|
历史数据的年份口径需要整体调整时:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/shift_year.py --dry-run # 先预演,看清楚了再执行
|
||||||
|
python scripts/shift_year.py # 全部 +1 年
|
||||||
|
python scripts/shift_year.py --years -1 # 反向回滚
|
||||||
|
```
|
||||||
|
|
||||||
|
> 内部按年份从大到小更新,避免 2024→2025 撞上尚未更新的 2025;执行后会校验 sort_key 无重复,有重复则整体回滚。
|
||||||
|
> 执行前建议手动备份:`cp data/board.db data/board.db.bak`
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /Users/jiliu/WorkSpace/unis_manager
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
打开 <http://127.0.0.1:8770>
|
||||||
|
|
||||||
|
首次运行 `run.sh` 会自动完成:选择带依赖的 Python 解释器 → 缺依赖则安装 → 建库 → 导入历史 Excel → 拆分定制交付项目,然后启动并打开浏览器。
|
||||||
|
|
||||||
|
### 启动方式与排错
|
||||||
|
|
||||||
|
| 场景 | 命令 |
|
||||||
|
|---|---|
|
||||||
|
| 正常启动 | `./run.sh` |
|
||||||
|
| 报 `permission denied` | `chmod +x run.sh` 后重试(**不要用 sudo**) |
|
||||||
|
| 不想改权限 | `bash run.sh` |
|
||||||
|
| 换端口 | `PORT=8771 ./run.sh` |
|
||||||
|
| 开发模式热加载 | `DEV=1 ./run.sh` |
|
||||||
|
| 不自动开浏览器 | `NO_OPEN=1 ./run.sh` |
|
||||||
|
| 提示"端口已被占用" | 原窗口 `Ctrl+C`,或 `lsof -ti:8770 \| xargs kill` 后重跑 |
|
||||||
|
|
||||||
|
> ⚠️ 不要用 `sudo` 启动:数据库文件会变成 root 属主,后续写入会失败。
|
||||||
|
|
||||||
|
> **DEV=1 热加载**:uvicorn 除 `*.py` 外还会监听 `web/**` 的 js/css/html 与 `.env`,改动即重启;
|
||||||
|
> 页面里的 `web/livereload.js` 每秒读一次 `/health` 的启动代际,发现服务重启后自动刷新浏览器。
|
||||||
|
> 非 DEV 启动时该脚本首轮即自行停止,`data/*.db` 的写入也不会触发重启。
|
||||||
|
|
||||||
|
### 手动分步执行(等价于 run.sh 干的事)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python scripts/init_db.py
|
||||||
|
python scripts/import_excel.py "/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx"
|
||||||
|
python scripts/migrate_custom.py
|
||||||
|
python -m uvicorn app.main:app --port 8770
|
||||||
|
```
|
||||||
|
|
||||||
|
> 重新导入:`python scripts/import_excel.py <xlsx> --reset` 之后再跑一次 `python scripts/migrate_custom.py`
|
||||||
|
> (迁移脚本会删除常规表里的「定制开发」容器,重复执行前请先 reset)
|
||||||
|
|
||||||
|
## 功能
|
||||||
|
|
||||||
|
### 1. 工作看板
|
||||||
|
|
||||||
|
进来第一屏:三类工作在这一页从「一览」点到「详情」,左栏挑一条,右栏跟着联动。
|
||||||
|
|
||||||
|
| 区域 | 内容 |
|
||||||
|
|---|---|
|
||||||
|
| 顶部 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` 后合并,分堆也在这一步判定(按条目的**录入周**:本周录入的 + 更早周次标了「延期」又没做完的结转条目)。
|
||||||
|
|
||||||
|
定制项目的口径:`/api/weekly` 返回**本期有交付记录**、**被标为重点**或**本周期新入库**(`is_new`:入库日期落在本周期首末日之内)的定制项目;右栏总览默认只列「本周新增」与「本周有变化」两段,其余只在组头计数里出现,并在底部提示「另有 N 个在途定制项目本周没有动态」。勾选总览头部的「列入全部在途」后,后端补上处于在途阶段(商机 → 已验收)的项目,总览多出一段「其他在途项目」。头部那枚「合同额合计」只加总**当前列出来的行**,勾与不勾数字会跟着清单变。
|
||||||
|
|
||||||
|
### 2. 项目管理
|
||||||
|
|
||||||
|
长期重点项目(`projects` + `tasks` + `updates`)。执行记录的写入逻辑没变,仍旧统一走「更新录入」。
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **项目管理** | 表格展示分类、负责人、状态、进度、更新情况(标签只在详情抽屉里维护,列表不放,避免挤占名称列);**列表只读**,行内不放任何输入框和下拉,每行只有 **详情 / 修改 / 归档** 三个按钮(操作列吸附在表格右侧,横向滚动也不会被挤出可视区);负责人筛选下拉来自人力资源库;支持新建与「含归档」筛选。详情抽屉里的**周期反馈**可切「按周 / 按月」与「本周 · 近 4 周 · 近 12 周 · 全部」(按月为「本月 · 近 3 月 · 近 6 月 · 全部」),用 ‹ › 翻页看更早的周期,默认跟随顶栏选中的周期 |
|
||||||
|
|
||||||
|
> 「执行面板」不再单列模块,已折进工作看板底部的「统计概览」折叠区。
|
||||||
|
|
||||||
|
#### 项目维护操作
|
||||||
|
|
||||||
|
| 操作 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **面板分区** | 抽屉用标准对话框式标题栏:**标题在左(下面一行副标题),基础信息的操作按钮成排在右,✕ 在最右**,按钮条嵌在标题栏里(不悬浮、不吸顶,滚动时跟着标题栏常驻)。详情档是「删除 · 归档/恢复 · 标记重点 · **修改**」,修改与新增档是「取消 · **保存/添加**」,主按钮永远在最右、危险动作在最左,按钮文字不再重复对象名(标题栏已经写了是哪个项目/条目)。附表的写入另放一处:**绿虚线 = 附表 · 更新记录**条仍留在它自己的附表标题下面,只有「+ 录一笔「某周期」执行记录 / 交付记录」,点它跳到「更新录入」并自动带上该项目与顶栏当前选中的周期。附表的写入统一走更新录入(粘贴原文 → AI 整理 → 确认写入),抽屉里不另做一套表单,避免两条路写出两份数据。基础信息的按钮在标题栏、附表的更新按钮在附表上方,两类按钮不在同一个位置混着放;编辑与新增档的报错信息显示在正文最上方;周期反馈上方的 按周/按月 + 本周/近 4 周/… 标成「查看范围(只读筛选)」,只翻看不改数据 |
|
||||||
|
| **改名** | 改名就是改基本信息:在详情面板点「修改」,表单第一行即项目名称(项目管理与定制交付同一套逻辑)。名称非空校验 + 重名检测,失败信息显示在面板正文最上方,不弹提示跑路 |
|
||||||
|
| **负责人与团队** | **多负责人只适用于重点项目**:重点项目可以指定**多个负责人**与**团队成员**,定制交付只有一个**交付负责人**(单人下拉)外加**团队成员**。人名一律从人力资源库下拉里选。**界面上负责人不分主次**:名单横向排开自动换行,每一位都带 × 可删(没有「主」标记、没有不给删的首行),「+ 添加负责人 / + 添加成员」跟在名单末尾。名单只在详情抽屉的「修改」档维护;只读处一律一排对等的小标签:重点项目的列表与详情把负责人摆成对等的一排,定制交付的列表与详情只显示一个交付负责人加团队成员标签。存储与统计口径不变:**重点项目 `owner` 字段仍是 `owners[0]`**(删掉第一位就由后一位顶上),看板、导出、人力负载与 KPI 按它归属,修改档的提示行写明了这点;重点项目**所有负责人都算命中**,定制交付按交付负责人命中;团队成员不参与该筛选 |
|
||||||
|
| **归档** | 项目从看板与列表默认视图中隐藏,数据完整保留;勾选「含归档」可查看,随时可恢复 |
|
||||||
|
| **删除** | 高危操作:弹窗列出将连带删除的子任务数与执行记录数,并且必须**逐字输入项目名称**,「永久删除」按钮才会点亮。**删除后不可恢复**,仅想隐藏请用归档 |
|
||||||
|
|
||||||
|
> 定制交付的「项目明细」表同理:列表只读,操作列只有 详情 / 修改 / 归档,改名 / 名单 / 删除都在抽屉里做。
|
||||||
|
|
||||||
|
#### 项目合并
|
||||||
|
|
||||||
|
把两个项目合并成一个(例如把「智空无人机平台」并入「智飞无人机平台」):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/merge_project.py --from "智空无人机平台" --to "智飞无人机平台" --dry-run
|
||||||
|
python scripts/merge_project.py --from "智空无人机平台" --to "智飞无人机平台"
|
||||||
|
```
|
||||||
|
|
||||||
|
- 子任务同名 → 归并到目标项目的同名子任务,执行记录一并转挂
|
||||||
|
- 子任务仅源项目有 → 直接转到目标项目名下(保留原名)
|
||||||
|
- 分类 / 负责人 / 描述 / 标签仅在目标缺失时继承
|
||||||
|
- 合并后按最新记录回算目标项目的进度与状态
|
||||||
|
- 执行前建议备份:`cp data/board.db data/board.db.bak`
|
||||||
|
|
||||||
|
### 3. 定制交付
|
||||||
|
|
||||||
|
| 模块 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **交付流水线**(默认页签) | 卡头给「在库 N 个 · 重点 N 个 · 本期更新 N 个」,漏斗本身按环节给项目数与金额(页签上不再叠一条全局 KPI 带,金额与项目数按环节累计);「入库 → 签单 → 交付 → 验收 → 计收」五段漏斗,每段给项目数与金额;口径为**商务阶段 + 已录环节日期**取先到哪一步算哪一步(环节日期还没补全时由商务阶段兜底)。下方「卡点项目」列出最后一个**有日期**的环节停留超过 `CUSTOM_STEP_AGING_DAYS`(默认 45)天且尚未计收的项目,可一键「处理」跳到详情抽屉补录 |
|
||||||
|
| **阶段看板** | 按「商机 → 已签单 → 开发中 → 交付完成 → 已验收 → 已计收 → 暂停/异常」分列展示,卡片带金额、区域、开发进度、可确认收入;列头显示项目数与金额合计。卡片只是入口,点卡片开详情抽屉;卡上的 ★ 只读(重点标记在详情面板的基础信息区改),外层不做任何直接写入 |
|
||||||
|
| **财务跟踪** | 页签内一排 KPI:合同总额 / 按进度可确认收入 / 已计收 / 待计收 / 已开票 / 已回款 / 待开票 / 待回款(各带比例脚注);再往下是阶段金额分布、周期活跃度、按负责人与区域汇总、待回款排行。财务口径的汇总只在这一页出现,环节与账期一屏对齐,方便财务逐月对账 |
|
||||||
|
| **项目明细** | 表头一行给出当前列表的合同额 / 可确认 / 已计收 / 开票 / 回款合计;按入库时间倒序的**只读**总表:区域 / 负责人 / **入库 · 签单 · 交付 · 验收日期** / 阶段 / 合同额 / 进度 / 可确认 / 已计收 / **开票 / 回款**(金额列统一以「万」计)/ 备注;改数据点行尾「修改」 |
|
||||||
|
| **详情抽屉** | 分「详情 / 修改」两档:详情只读,修改才给表单(含项目名称、手动确认比例、**交付环节五个日期**、区域下拉、**开票与回款金额**)+ 收入确认卡 + 周期执行记录 + 阶段流转时间线。负责人从人力资源库里选。基础信息的动作在标题栏按钮条上(修改/保存,正文里标明不会动下面的交付记录),开票与回款金额跟着标题栏的「保存」一起提交;附表的绿虚线条(「+ 录一笔」跳更新录入 · 定制交付并带上项目与周期)留在附表标题下 |
|
||||||
|
|
||||||
|
### 4. 每周重点工作
|
||||||
|
|
||||||
|
不值得建项目档案的短期活儿:一条就是一个活儿,不用先建项目,录进来就能按周跟踪。
|
||||||
|
|
||||||
|
| 区块 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| **新增** | 工具栏「+ 新增重点工作」开**独立面板**(列表顶部那张内嵌录入卡已撤掉):**工作内容**(必填,一句话说清干什么)/ **说明**(可选)/ **录入周**(只读=顶栏当前选中的那一周,切周再录就归到那一周)/ **预计完成时间**(日历日期,只是目标日期,**不决定条目归到哪一周**)/ **执行状态**(取 `constants.STATUSES`)/ **结果反馈** / **是否延期**(人工开关)|
|
||||||
|
| 列表 | 工作内容 / 说明(截断预览)/ 预计完成时间(下面小字标「录入 X月第N周」)/ 状态(标了延期且没完成的挂红色「延期」徽章)/ 更新日期 / 操作 **详情 · 修改 · 归档 · 删除**——和两张项目列表同一套规矩:列表只读,行内不放输入框 |
|
||||||
|
| 筛选 | **本周 / 延期 / 已完成 / 全部** 四个堆(chip 上实时显示各堆条数)+「含归档条目」;顶栏切周即换基准周,工具栏标明「本周/延期按录入周统计」;顶部全局搜索同时匹配工作内容与说明 |
|
||||||
|
| 详情抽屉 | 同样分「详情 / 修改」两档,**基础信息的按钮全在抽屉标题栏右侧的按钮条上**:详情档是「删除 · 归档 · **修改**」,修改与新增档是「取消 · **添加/保存**」,报错信息显示在正文最上方。点「修改」才出表单,**录入周只读**(改预计完成时间不会把条目搬去别的周)。重点工作条目没有附表,所以抽屉里不出现绿的更新条 |
|
||||||
|
| 删除 | 列表行与抽屉顶部都能删,走同一个确认弹窗,并提示「只是这周不再跟进?请改用归档」。条目不挂子任务与执行记录,**不要求逐字输入名称**——逐字输入项目名称只用在项目管理与定制交付的项目删除上 |
|
||||||
|
| 导出 | 右上角「导出 Excel」→ `GET /api/export/focus`,带上当前筛选条件 |
|
||||||
|
| 联动 | 工作看板左栏**第一组**「本周工作重点」与 KPI「本周重点工作」读的就是这张表;看板右栏点条目给只读详情,改内容走抽屉 |
|
||||||
|
|
||||||
|
分堆口径(基准周 = 顶栏选中的那一周,一律按**录入周**算,和预计完成时间无关):录入周**等于**基准周 → **本周**;录入周**早于**基准周且「是否延期」打开且未完成 → **延期**(上周及更早没做完的活儿结转进来);状态为「已完成」→ **已完成**(交叉视图,条目仍留在自己那一周)。
|
||||||
|
「更早周次录入、没标延期又没完成」的条目不进当周视图,只在**全部**里查得到——所以每周收尾时,没做完的记得勾上「延期」,它才会结转进下周。库里不存「逾期」字段,延期是人工开关。
|
||||||
|
|
||||||
|
### 人力资源库
|
||||||
|
|
||||||
|
侧栏「人力资源」,是全系统**负责人姓名的唯一来源**(`staff` 表)。
|
||||||
|
|
||||||
|
| 区块 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 顶部 KPI | 在册人数与带项目人数、各归属小组人数、人员归属(汇智 / 云数 / 外服)分布,以及**花名册外的负责人**核对(历史数据里对不上花名册的人名,一个都不藏) |
|
||||||
|
| 花名册 | 姓名 / 归属小组 / 人员归属 / 说明 + 该人的**在管项目数、定制项目数、定制合同额、名下待计收、最近更新**;小组与归属可行内改(下拉),说明行内编辑失焦即存 |
|
||||||
|
| 筛选与导出 | 在编 / 已归档 / 全部 + 按小组筛选;「导出 Excel」按当前筛选导出 |
|
||||||
|
| 新增 | 弹窗录入:姓名必填且全库唯一,小组与归属下拉取自 `constants.STAFF_GROUPS` / `STAFF_AFFILIATIONS` |
|
||||||
|
| 改名 | 走「编辑」改姓名时,**名下项目与定制项目的负责人、其他负责人、团队成员一并同步改掉**,历史数据不会断链 |
|
||||||
|
| 归档 / 恢复 | 归档后不再出现在「负责人」下拉里,但历史项目仍完整保留其负责人;随时可恢复。离职人员用归档,不要用删除 |
|
||||||
|
| 删除 | 名下还挂着项目或定制项目时接口直接返回 400 并说明原因,防止把在管项目变成无主项目 |
|
||||||
|
|
||||||
|
导入名单(表头按名称匹配:序号 / 姓名 / 归属小组 / 人员归属 / 说明):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/import_staff.py --dry-run # 只看解析结果
|
||||||
|
python scripts/import_staff.py # 增量导入(同名跳过,不覆盖页面上的编辑)
|
||||||
|
python scripts/import_staff.py --reset # 清空后重新导入
|
||||||
|
python scripts/import_staff.py <xlsx> # 指定别的文件;首次运行 ./run.sh 会自动导入
|
||||||
|
```
|
||||||
|
|
||||||
|
**负责人下拉**(`ownerSelectHtml`)在项目新建/详情、定制项目新建/详情、工作看板等位置统一使用,选项来自花名册:
|
||||||
|
已归档的人不再作为可选项出现,只有他本来就是这条记录的负责人时才带「(已归档)」标注出现一次;
|
||||||
|
历史数据里花名册没有的人名同样补一个「(不在花名册)」选项,保证打开老项目不会把负责人改没。
|
||||||
|
**多负责人与团队成员**复用同一个下拉(`peopleEditorHtml`,现在只有重点项目的抽屉用它):人名下拉**横向排开、自动换行**,选项同样只有花名册里的人,所以新增成员不需要另外维护一份名单;名单里的每一位都带 × 可删,界面上不区分主次。定制交付的负责人是普通的单个 `ownerSelectHtml` 下拉,只有团队成员用这套名单编辑器。
|
||||||
|
|
||||||
|
### 入库时间
|
||||||
|
|
||||||
|
定制项目新增「入库时间」字段,当前口径为 **该项目最早记录所在周期的第一天(周一)**:
|
||||||
|
|
||||||
|
- 优先取原始《项目明细表》台账中该项目所在的周(该列是稀疏填写的,导入时按周分组向下继承)
|
||||||
|
- 台账没有的,取《周工作情况总结》「定制开发」块中该项目首次出现的周
|
||||||
|
- 再没有,依次退回 custom_updates 最早记录周期 → 项目所属周期 → 记录创建日期
|
||||||
|
|
||||||
|
周期换算规则:以**该月第一个周一**所在周为第 1 周,第 N 周顺延 `7×(N-1)` 天。
|
||||||
|
例如 `2026年8月第4周` → `2026-08-24`(2026-08-01 是周六,第一个周一是 08-03)。
|
||||||
|
|
||||||
|
字段可在明细表中直接改,也可在抽屉里改;重新计算用:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python scripts/add_entry_date.py --dry-run # 预演
|
||||||
|
python scripts/add_entry_date.py # 回填(跳过已有值)
|
||||||
|
python scripts/add_entry_date.py --force # 全部重算
|
||||||
|
```
|
||||||
|
|
||||||
|
### 区域(省级行政区)
|
||||||
|
|
||||||
|
- 编辑时是**规范下拉**,34 个省级行政区按大区分组(华北 / 东北 / 华东 / 华中 / 华南 / 西南 / 西北)
|
||||||
|
- 列表筛选下拉同样使用规范名称,只列出实际有项目的区域
|
||||||
|
- 历史数据里若出现「内蒙古自治区」这类非规范写法,保存时会自动归一为「内蒙古」
|
||||||
|
- 早期从项目名推断的区域(`infer_region`)与标准列表口径一致,无需迁移
|
||||||
|
|
||||||
|
## 界面设计语言
|
||||||
|
|
||||||
|
前端是零构建的原生 JS(`web/index.html` + `web/styles.css` + `web/app.js` + `web/icons.js`),设计口径固定如下,新增页面请沿用:
|
||||||
|
|
||||||
|
- **禁用「彩色描边」**:卡片、列表、步骤条、表格一律中性灰边框(`--border` / `--border-strong`)或无边框,不用圆角多色边框表达状态。
|
||||||
|
- **重点色只出现在三处**:图标、关键数字/文本、小面积 tinted 底板(`.ic-badge`、`.tag-mini`、`.kpi.accent-*`)。状态色(`statusMeta`)与阶段色(`stageMeta`)是唯一配色来源,避免各页面自己调色。
|
||||||
|
- **图标系统**:`web/icons.js` 内置 50 个 24×24 线性 SVG,`stroke=currentColor`,颜色跟着文字走。对外 API:
|
||||||
|
`svg(name)` 裸图标;`badge(name)` 图标 + tinted 底板(KPI 用);`status()` / `stage()` / `pipe()` 按状态·阶段·交付环节取图标;
|
||||||
|
`nameOfStatus()` / `nameOfStage()` / `statusBadge()` 出文案与徽标;`hydrate()` 填 `[data-icon]`;`auto()` 自动补前导图标;`names()` 列全部图标名。
|
||||||
|
- **自动图标**:`auto()` + `MutationObserver` 在每次渲染后按标题关键词给 `.card-head h4` / `.sec-title` / `.kcol-title` / `.wk-group-head` / `.notice` / `.empty` 加前导图标,并填充 `[data-icon]`;用 `data-ico` 属性防重复,标题里**自带** `<svg class="ic">`(如阶段看板按阶段取图标)时跳过自动补图,不会出现两个图标。新增区块只要标题里带对应关键词就自动带图标,不用手写。
|
||||||
|
- **阶段看板列头不带色条**:列头用 `Ico.stage(code)` + `--c` 注入阶段色(`.kcol-title .ic { color: var(--c) }`),早期那种 `border-top:3px solid 阶段色` 的彩条已去掉。
|
||||||
|
- **交付环节图标按 `code` 取**:后端 `CUSTOM_PIPELINE` 下发的字段是 **`code`**(不是 `key`),前端一律 `Ico.pipe(s.code)`;写成 `s.key` 会静默退化成通用圆点。
|
||||||
|
- **表格防竖排**:`table.tbl th` / `.pill` / `.tag-mini` 统一 `white-space:nowrap`,容易挤窄的列(区域、负责人)在 `<td>` 上加 `class="nowrap"`;实在挤不下交给 `.tbl-wrap { overflow:auto }` 横向滚动。
|
||||||
|
- **抽屉不被内容撑破**:`.form-row > div { min-width: 0 }`(grid 子项默认 `min-width:auto`,会被交付环节步骤条撑出整表横向滚动),步骤条本身 `flex:1 1 132px`(132px = 日期输入框含日历图标的最小可用宽度)。
|
||||||
|
|
||||||
|
> 备注:`styles.css` 里的 `.pcard`、`.board-grid` 当前没有任何 JS 引用(历史遗留样式),此处只做记录,未删除。
|
||||||
|
|
||||||
|
## 数据导出
|
||||||
|
|
||||||
|
各列表页右上角均有 **导出 Excel**,导出内容会带上页面当前的筛选条件:
|
||||||
|
|
||||||
|
| 位置 | 导出内容 | 接口 |
|
||||||
|
|---|---|---|
|
||||||
|
| 定制交付 | 定制项目明细(含区域、金额、进度、收入确认、入库时间、备注 + 合计行) | `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` |
|
||||||
|
|
||||||
|
导出的 xlsx 带表头样式、冻结首行、自动筛选,金额列按千分位格式;文件名含导出日期。
|
||||||
|
|
||||||
|
## 收入确认口径
|
||||||
|
|
||||||
|
定制项目按**完工百分比法**确认收入:
|
||||||
|
|
||||||
|
| 阶段 | 确认比例 |
|
||||||
|
|---|---|
|
||||||
|
| 商机/报备 | 0% |
|
||||||
|
| 已签单/下单 | 10% |
|
||||||
|
| 开发中 | 10% + 40% × 开发进度 |
|
||||||
|
| 交付完成 | 70% |
|
||||||
|
| 已验收 | 90% |
|
||||||
|
| 已计收 | 100% |
|
||||||
|
| 暂停/异常 | 30% |
|
||||||
|
| 已关闭 | 100% |
|
||||||
|
|
||||||
|
- **可确认收入** = 合同额 × 上表比例
|
||||||
|
- **待计收** = 可确认收入 − 已计收
|
||||||
|
- **待开票** = 合同额 − 已开票、**待回款** = 合同额 − 已回款(均不小于 0,随开票 / 回款金额自动更新)
|
||||||
|
- 开票与回款金额在项目详情抽屉、定制交付详情抽屉、工作看板右栏都能直接填
|
||||||
|
- 项目上可填「手动确认比例」覆盖阶段推算(用于预付、分批收款等特殊情形)
|
||||||
|
- 比例定义见 `app/constants.py::CUSTOM_STAGES` 与 `custom_revenue_ratio()`,改一处即可全局生效
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
```
|
||||||
|
# 项目管理(长期重点项目)
|
||||||
|
Period(周期: 年/月/周, sort_key)
|
||||||
|
└── Project(分类, 负责人 owners[0], 其他负责人, 团队成员, 状态, 优先级, 重点标记, 进度, 标签)
|
||||||
|
├── Task(子任务)
|
||||||
|
└── Update(执行情况: 正文, 摘要, 进度, 状态, 风险, 下一步, 来源, 原文)
|
||||||
|
|
||||||
|
# 定制交付(独立)
|
||||||
|
CustomProject(名称, 客户, 区域, 合同额, 阶段, 开发进度, 手动确认比例,
|
||||||
|
已计收金额, 交付负责人(单人), 团队成员, 入库/签单/交付/验收/计收日期, 开票/回款金额, 重点, 归档)
|
||||||
|
# 定制交付不做多负责人:other_owners 列保留在库里但不再读写
|
||||||
|
└── CustomUpdate(周期, 阶段, 内容, 进度, 本期新增下单, 本期新增计收, 风险, 下一步)
|
||||||
|
|
||||||
|
# 每周重点工作(独立:条目不是项目,不挂子任务与执行记录)
|
||||||
|
WeeklyFocus(工作内容, 说明, 录入周→Period, 预计完成时间, 执行状态, 是否延期, 结果反馈, 归档)
|
||||||
|
|
||||||
|
Staff(姓名, 归属小组, 人员归属, 说明, 排序, 归档) # 花名册:所有「负责人 / 团队成员」下拉的唯一来源
|
||||||
|
Category / Tag / Setting / ImportLog
|
||||||
|
LedgerItem(客户项目台账) # 只剩模型与 Excel 导入解析,界面无入口、接口已下线
|
||||||
|
```
|
||||||
|
|
||||||
|
原表映射关系:
|
||||||
|
|
||||||
|
- `周工作情况总结`:**时间** → Period;**工作内容** → Project;**内容说明** → Task;**完成情况** → Update
|
||||||
|
(三列均为稀疏填充,导入时逐级向下继承;仅填「完成情况」的行作为上一条记录的续行合并)
|
||||||
|
- 原「定制开发」的 92 个子任务 → `custom_projects`(名称尾部的金额如「(39,400.00)」会被解析成合同额)
|
||||||
|
- `项目明细表` 39 条台账 → `custom_projects`(区域 / 金额 / 类别→阶段 / 状态说明),与子任务同名时自动合并
|
||||||
|
|
||||||
|
## AI 整理
|
||||||
|
|
||||||
|
两套体系各有独立提示词:
|
||||||
|
|
||||||
|
- **项目管理**(常规执行记录):抽取 摘要 / 进度 / 状态 / 子任务列表 / 风险 / 下一步
|
||||||
|
- **定制交付类**:抽取 商务阶段 / 开发进度 / 合同额 / 累计计收 / 本期新增计收 / 摘要 / 风险 / 下一步 / 本期明细
|
||||||
|
- **每周重点工作**不过 AI:在独立面板里直接填(工作内容 / 说明 / 预计完成时间填日历日期 / 执行状态)
|
||||||
|
|
||||||
|
- 默认走 **OpenAI 兼容协议**,支持 DeepSeek、通义千问(兼容模式)、Moonshot、智谱、OpenAI、本地 Ollama 等。
|
||||||
|
- Ollama 示例:Base URL `http://localhost:11434/v1`,模型 `qwen2.5:14b`,Key 任意填。
|
||||||
|
- **未配置 API Key 时自动降级为本地规则解析**:按编号切分事项、正则提取百分比与金额、
|
||||||
|
关键词判定阶段 —— 无需 Key 也能完成录入。
|
||||||
|
- 阶段判定做了特殊处理:文本里同时出现「已计收」和「进度 60%」时判为**开发中**,计收金额由 `revenue_delta` 承载。
|
||||||
|
- 解析结果写入前始终提供**可编辑预览**,确认后才落库,并留痕到 `import_logs`。
|
||||||
|
|
||||||
|
## 迁移到 MySQL
|
||||||
|
|
||||||
|
1. 安装驱动:`pip install pymysql cryptography`
|
||||||
|
2. 修改 `.env`:
|
||||||
|
```
|
||||||
|
DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/unis_board?charset=utf8mb4
|
||||||
|
```
|
||||||
|
3. 重启服务(表结构自动创建),再依次执行 `import_excel.py --reset` 与 `migrate_custom.py`。
|
||||||
|
|
||||||
|
数据访问层全部使用 SQLAlchemy ORM,无原生 SQL、无 SQLite 专有语法,切换方言无需改业务代码。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
main.py FastAPI 入口(API 挂载在 /api,静态页面在 /)
|
||||||
|
config.py 配置与 .env 加载(DATABASE_URL、AI 参数)
|
||||||
|
db.py engine / Session(按 URL 自动适配方言)
|
||||||
|
models.py ORM 模型(项目 + 定制项目 + 每周重点工作 + 花名册)
|
||||||
|
constants.py 状态/阶段常量、收入确认比例、区域与阶段识别
|
||||||
|
ai_parser.py LLM 解析 + 本地规则兜底(通用 / 定制两套)
|
||||||
|
routers/ board(周期/元数据/统计) · weekly(工作看板数据) · projects(项目管理) ·
|
||||||
|
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 数据库
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 速览
|
||||||
|
|
||||||
|
```
|
||||||
|
# 通用
|
||||||
|
GET /api/meta 状态字典 / 优先级 / AI 配置状态
|
||||||
|
GET /api/periods 周列表 + 按月聚合(含各周期记录数;today_id = 今天所在的周,顶栏默认落它)
|
||||||
|
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
|
||||||
|
(custom_new 本期新入库 / custom_updated 本期有交付记录;行上带 is_new)
|
||||||
|
(左栏第一组「本周工作重点」前端另取 /api/focus 合并)
|
||||||
|
GET /api/projects (?archived=false|true|all)
|
||||||
|
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=®ion=&keyword=
|
||||||
|
POST /api/custom/projects PATCH /api/custom/projects/{id} DELETE /api/custom/projects/{id}
|
||||||
|
(owner 是单个人名;members 传人名数组,整体替换:不传=不改,传 []=清空。不再接受 owners,多负责人只属于重点项目)
|
||||||
|
GET /api/custom/board?period_id=|year&month 阶段看板
|
||||||
|
GET /api/custom/stats?period_id=|year&month 收入统计
|
||||||
|
POST /api/custom/updates
|
||||||
|
POST /api/custom/ai/parse 粘贴文本 → 结构化预览
|
||||||
|
POST /api/custom/ai/commit 确认写入
|
||||||
|
|
||||||
|
# 每周重点工作
|
||||||
|
GET /api/focus?archived=false|true|all&status=&keyword= 条目清单(period_id/period_label/sort_key 都是**录入周**,分堆由调用方判定)
|
||||||
|
POST /api/focus PATCH /api/focus/{id} DELETE /api/focus/{id}
|
||||||
|
单条增改删;工作内容非空。period_id=录入周(前端传顶栏当前周,
|
||||||
|
不传后端按今天所在的周补;due_date 只是目标日期,不改归属周)
|
||||||
|
或直接给 period_id(必须存在);两者都不给 → 400
|
||||||
|
|
||||||
|
# 人力资源库
|
||||||
|
GET /api/staff?archived=false|true|all&group=&keyword= 花名册 + 各自工作量 + 花名册外负责人 + 小组/归属字典
|
||||||
|
POST /api/staff PATCH /api/staff/{id} DELETE /api/staff/{id}(名下有项目时拒绝,离职请归档)
|
||||||
|
|
||||||
|
# 其他
|
||||||
|
GET /api/settings PUT /api/settings
|
||||||
|
|
||||||
|
# 导出(均返回 xlsx)
|
||||||
|
GET /api/export/custom ?region=&stage=&keyword=&archived=
|
||||||
|
GET /api/export/custom-updates ?period_id=|year&month
|
||||||
|
GET /api/export/projects ?category_id=&keyword=&archived=
|
||||||
|
GET /api/export/staff ?archived=false|true|all
|
||||||
|
GET /api/export/focus ?archived=false|true|all&status=&keyword=
|
||||||
|
GET /api/export/updates ?period_id=|year&month&project_id=
|
||||||
|
```
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,412 @@
|
||||||
|
"""AI 整理:把粘贴的周报原文整理成结构化执行记录。
|
||||||
|
|
||||||
|
- 主路径:调用 OpenAI 兼容接口(DeepSeek / 通义 / Moonshot / OpenAI / Ollama ...)
|
||||||
|
- 兜底:本地规则解析,无需任何 Key 也能用
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .models import Setting
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """你是一位研发部门管理助理,负责把项目周报/月报原文整理成结构化的执行记录。
|
||||||
|
请严格输出 JSON(不要 Markdown 代码块、不要解释),结构如下:
|
||||||
|
{
|
||||||
|
"summary": "一句话摘要,30字以内",
|
||||||
|
"progress": 60,
|
||||||
|
"status": "in_progress",
|
||||||
|
"risks": ["风险或问题1", "风险或问题2"],
|
||||||
|
"next_steps": ["下一步计划1"],
|
||||||
|
"tasks": [
|
||||||
|
{"name": "子任务名称", "content": "该子任务的执行情况", "progress": 80, "status": "in_progress", "risk": ""}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
规则:
|
||||||
|
1. status 只能是 not_started / in_progress / done / at_risk / blocked / paused 之一。
|
||||||
|
2. progress 是 0-100 的整数;原文有百分比就用原文的,没有就根据语义估算。
|
||||||
|
3. tasks 尽量拆分原文中的多条独立事项,每条 name 不超过 20 字;原文没有明显分条时,tasks 允许只有 1 条。
|
||||||
|
4. risks 只放风险、阻塞、待协调事项;没有就给空数组。
|
||||||
|
5. 不要编造原文没有的事实。
|
||||||
|
"""
|
||||||
|
|
||||||
|
USER_TEMPLATE = """项目:{project}
|
||||||
|
周期:{period}
|
||||||
|
|
||||||
|
---- 原始内容 ----
|
||||||
|
{raw}
|
||||||
|
---- 结束 ----
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 配置读取
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def get_ai_settings(db) -> dict[str, str]:
|
||||||
|
values = dict(config.DEFAULT_AI_SETTINGS)
|
||||||
|
for row in db.query(Setting).all():
|
||||||
|
values[row.key] = row.value or ""
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def ai_enabled(db) -> bool:
|
||||||
|
s = get_ai_settings(db)
|
||||||
|
return bool((s.get("ai_api_key") or "").strip() and (s.get("ai_api_base") or "").strip())
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 规则兜底解析
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_CN_NUM = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
|
||||||
|
_SPLIT_RE = re.compile(r"(?:^|\n)\s*(?:\(?\d{1,2}[))、..::]|[一二三四五六七八九十]{1,2}[、..]|[-•*·]\s)")
|
||||||
|
_PCT_RE = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*%")
|
||||||
|
|
||||||
|
_STATUS_RULES = [
|
||||||
|
(("阻塞", "卡住", "卡点", "停滞", "无法推进", "推不动"), "blocked"),
|
||||||
|
(("暂停", "终止", "搁置"), "paused"),
|
||||||
|
(("延期", "延迟", "滞后", "赶不上", "超期"), "at_risk"),
|
||||||
|
(("风险", "隐患", "待协调"), "at_risk"),
|
||||||
|
(("未开始", "待启动", "未启动"), "not_started"),
|
||||||
|
(("完成", "已交付", "已验收", "已上线", "已发布"), "done"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_status(text: str) -> str:
|
||||||
|
"""先判负面信号,再用百分比判定,最后才用『完成』类关键词。"""
|
||||||
|
low = (text or "").lower()
|
||||||
|
pct = _guess_progress(text)
|
||||||
|
|
||||||
|
for words, code in _STATUS_RULES:
|
||||||
|
if code in ("done", "not_started"):
|
||||||
|
continue
|
||||||
|
if any(w in low for w in words):
|
||||||
|
return code
|
||||||
|
|
||||||
|
if pct is not None:
|
||||||
|
return "done" if pct >= 100 else "in_progress"
|
||||||
|
|
||||||
|
for words, code in _STATUS_RULES:
|
||||||
|
if code in ("done", "not_started") and any(w in low for w in words):
|
||||||
|
return code
|
||||||
|
return "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def _guess_progress(text: str) -> int | None:
|
||||||
|
from .constants import extract_percent
|
||||||
|
|
||||||
|
return extract_percent(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_items(raw: str) -> list[str]:
|
||||||
|
text = (raw or "").strip()
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
parts = _SPLIT_RE.split("\n" + text)
|
||||||
|
parts = [p.strip() for p in parts if p and p.strip()]
|
||||||
|
if len(parts) <= 1:
|
||||||
|
parts = [ln.strip() for ln in text.splitlines() if ln.strip()]
|
||||||
|
return parts or [text]
|
||||||
|
|
||||||
|
|
||||||
|
def _is_header(s: str) -> bool:
|
||||||
|
"""形如『本周 NEX云桌面 进展:』的标题行,不应成为子任务。"""
|
||||||
|
s = s.strip()
|
||||||
|
return len(s) <= 40 and bool(re.search(r"[::]\s*$", s))
|
||||||
|
|
||||||
|
|
||||||
|
def rule_parse(raw: str, project_name: str = "") -> dict[str, Any]:
|
||||||
|
items = _split_items(raw)
|
||||||
|
if len(items) > 1:
|
||||||
|
items = [i for i in items if not _is_header(i)] or items
|
||||||
|
tasks: list[dict[str, Any]] = []
|
||||||
|
for it in items[:12]:
|
||||||
|
flat = it.replace("\n", " ").strip()
|
||||||
|
tasks.append(
|
||||||
|
{
|
||||||
|
"name": flat[:20] + ("…" if len(flat) > 20 else ""),
|
||||||
|
"content": it,
|
||||||
|
"progress": _guess_progress(it),
|
||||||
|
"status": _guess_status(it),
|
||||||
|
"risk": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
flat_all = (raw or "").replace("\n", " ")
|
||||||
|
overall_status = _guess_status(flat_all)
|
||||||
|
if tasks:
|
||||||
|
codes = [t["status"] for t in tasks]
|
||||||
|
for cand in ("blocked", "paused", "at_risk", "not_started", "in_progress", "done"):
|
||||||
|
if cand in codes:
|
||||||
|
overall_status = cand
|
||||||
|
break
|
||||||
|
|
||||||
|
progresses = [t["progress"] for t in tasks if t["progress"] is not None]
|
||||||
|
progress = int(round(sum(progresses) / len(progresses))) if progresses else None
|
||||||
|
|
||||||
|
risks, next_steps = [], []
|
||||||
|
for ln in (raw or "").splitlines():
|
||||||
|
ln = ln.strip()
|
||||||
|
if not ln:
|
||||||
|
continue
|
||||||
|
if any(w in ln for w in ("风险", "阻塞", "问题", "待协调", "无法", "延期", "延迟")):
|
||||||
|
risks.append(ln[:120])
|
||||||
|
if any(w in ln for w in ("下一步", "下周", "计划", "后续", "待办")):
|
||||||
|
next_steps.append(ln[:120])
|
||||||
|
|
||||||
|
summary = ""
|
||||||
|
if items:
|
||||||
|
summary = items[0].replace("\n", " ").strip()[:60]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": summary,
|
||||||
|
"progress": progress,
|
||||||
|
"status": overall_status,
|
||||||
|
"risks": risks[:5],
|
||||||
|
"next_steps": next_steps[:5],
|
||||||
|
"tasks": tasks,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# LLM 解析
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def _extract_json(text: str) -> dict[str, Any]:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if text.startswith("```"):
|
||||||
|
text = re.sub(r"^```(?:json)?", "", text).strip()
|
||||||
|
text = re.sub(r"```$", "", text).strip()
|
||||||
|
start, end = text.find("{"), text.rfind("}")
|
||||||
|
if start >= 0 and end > start:
|
||||||
|
text = text[start : end + 1]
|
||||||
|
return json.loads(text)
|
||||||
|
|
||||||
|
|
||||||
|
def llm_parse(raw: str, project_name: str, period_label: str, db) -> dict[str, Any]:
|
||||||
|
s = get_ai_settings(db)
|
||||||
|
base = (s.get("ai_api_base") or "").rstrip("/")
|
||||||
|
payload = {
|
||||||
|
"model": s.get("ai_model") or "deepseek-chat",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": USER_TEMPLATE.format(
|
||||||
|
project=project_name or "(未指定)",
|
||||||
|
period=period_label or "(未指定)",
|
||||||
|
raw=raw[:12000],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temperature": float(s.get("ai_temperature") or 0.2),
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {s.get('ai_api_key') or ''}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=config.AI_TIMEOUT) as client:
|
||||||
|
resp = client.post(f"{base}/chat/completions", json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
parsed = _extract_json(content)
|
||||||
|
parsed.setdefault("tasks", [])
|
||||||
|
parsed.setdefault("risks", [])
|
||||||
|
parsed.setdefault("next_steps", [])
|
||||||
|
parsed["model"] = s.get("ai_model")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
# ==========================================================================
|
||||||
|
# 定制交付项目:阶段 / 金额 / 计收
|
||||||
|
# ==========================================================================
|
||||||
|
CUSTOM_SYSTEM_PROMPT = """你是定制交付项目的管理助理,负责把项目周报原文整理成结构化记录。
|
||||||
|
定制项目关注的是商务阶段流转(商机→签单→开发→交付→验收→计收)与收入确认。
|
||||||
|
请严格输出 JSON(不要 Markdown 代码块、不要解释),结构如下:
|
||||||
|
{
|
||||||
|
"stage": "developing",
|
||||||
|
"progress": 60,
|
||||||
|
"amount": 150000,
|
||||||
|
"revenue_amount": 0,
|
||||||
|
"revenue_delta": 0,
|
||||||
|
"summary": "一句话摘要,30字以内",
|
||||||
|
"risks": ["风险或问题1"],
|
||||||
|
"next_steps": ["下一步计划1"],
|
||||||
|
"updates": [{"content": "本期执行情况", "progress": 60, "stage": "developing", "revenue_delta": 0}]
|
||||||
|
}
|
||||||
|
规则:
|
||||||
|
1. stage 只能是 lead / signed / developing / delivered / accepted / revenue / paused / closed 之一。
|
||||||
|
2. amount 是合同或订单金额(数字,单位元);原文没有就给 null。
|
||||||
|
3. revenue_amount 是累计已计收金额;revenue_delta 是本期新增计收;没有就给 0 或 null。
|
||||||
|
4. progress 是开发进度 0-100 的整数,仅在 developing 阶段有意义,其它阶段可按语义填 100。
|
||||||
|
5. 不要编造原文没有的金额和数字。
|
||||||
|
"""
|
||||||
|
|
||||||
|
CUSTOM_USER_TEMPLATE = """定制项目:{project}
|
||||||
|
周期:{period}
|
||||||
|
当前阶段:{stage}
|
||||||
|
|
||||||
|
---- 原始内容 ----
|
||||||
|
{raw}
|
||||||
|
---- 结束 ----
|
||||||
|
"""
|
||||||
|
|
||||||
|
_AMT_RE = re.compile(r"(?:金额|合同额|合同金额|下单|订单|报价)?\s*[¥¥]?\s*(\d[\d,,]*(?:\.\d+)?)\s*(元|万|万元)?")
|
||||||
|
_REVENUE_RE = re.compile(r"(?:计收|已计收|核销|回款)[^0-9]{0,8}([0-9][0-9,,]*(?:\.\d+)?)")
|
||||||
|
|
||||||
|
|
||||||
|
def _to_number(s: str | None) -> float | None:
|
||||||
|
if s is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(str(s).replace(",", "").replace(",", ""))
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_amount(text: str) -> float | None:
|
||||||
|
"""从『金额 150,000 元』『下单 39,400.00』等表述中提取金额。"""
|
||||||
|
m = re.search(r"(?:金额|合同额|合同金额|下单|订单|中标|报价)\s*[::为]?\s*[¥¥]?\s*([0-9][0-9,,]*(?:\.[0-9]+)?)", text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
v = _to_number(m.group(1))
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
tail = text[m.end() : m.end() + 2]
|
||||||
|
if "万" in tail:
|
||||||
|
v *= 10000
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_revenue(text: str) -> float | None:
|
||||||
|
m = _REVENUE_RE.search(text or "")
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
v = _to_number(m.group(1))
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
tail = (text or "")[m.end() : m.end() + 2]
|
||||||
|
if "万" in tail:
|
||||||
|
v *= 10000
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def rule_parse_custom(raw: str, project_name: str = "") -> dict[str, Any]:
|
||||||
|
from .constants import guess_custom_stage
|
||||||
|
|
||||||
|
flat = (raw or "").replace("\n", " ")
|
||||||
|
stage = guess_custom_stage(flat)
|
||||||
|
progress = _guess_progress(flat)
|
||||||
|
items = _split_items(raw)
|
||||||
|
|
||||||
|
updates = []
|
||||||
|
for it in items[:12]:
|
||||||
|
updates.append(
|
||||||
|
{
|
||||||
|
"content": it,
|
||||||
|
"progress": _guess_progress(it),
|
||||||
|
"stage": guess_custom_stage(it),
|
||||||
|
"revenue_delta": _extract_revenue(it),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
risks, next_steps = [], []
|
||||||
|
for ln in (raw or "").splitlines():
|
||||||
|
ln = ln.strip()
|
||||||
|
if not ln:
|
||||||
|
continue
|
||||||
|
if any(w in ln for w in ("风险", "阻塞", "问题", "待协调", "无法", "延期", "延迟")):
|
||||||
|
risks.append(ln[:120])
|
||||||
|
if any(w in ln for w in ("下一步", "下周", "计划", "后续", "待办")):
|
||||||
|
next_steps.append(ln[:120])
|
||||||
|
|
||||||
|
if stage in ("delivered", "accepted", "revenue", "closed") and progress is None:
|
||||||
|
progress = 100
|
||||||
|
if stage == "developing" and progress is None:
|
||||||
|
progress = 30
|
||||||
|
|
||||||
|
return {
|
||||||
|
"stage": stage,
|
||||||
|
"progress": progress,
|
||||||
|
"amount": _extract_amount(flat),
|
||||||
|
"revenue_amount": None,
|
||||||
|
"revenue_delta": _extract_revenue(flat),
|
||||||
|
"summary": (items[0].replace("\n", " ").strip()[:60] if items else ""),
|
||||||
|
"risks": risks[:5],
|
||||||
|
"next_steps": next_steps[:5],
|
||||||
|
"updates": updates,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def llm_parse_custom(raw: str, project_name: str, period_label: str, stage: str, db) -> dict[str, Any]:
|
||||||
|
s = get_ai_settings(db)
|
||||||
|
base = (s.get("ai_api_base") or "").rstrip("/")
|
||||||
|
payload = {
|
||||||
|
"model": s.get("ai_model") or "deepseek-chat",
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": CUSTOM_SYSTEM_PROMPT},
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": CUSTOM_USER_TEMPLATE.format(
|
||||||
|
project=project_name or "(未指定)",
|
||||||
|
period=period_label or "(未指定)",
|
||||||
|
stage=stage or "(未知)",
|
||||||
|
raw=raw[:12000],
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"temperature": float(s.get("ai_temperature") or 0.2),
|
||||||
|
"stream": False,
|
||||||
|
}
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"Bearer {s.get('ai_api_key') or ''}",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
}
|
||||||
|
with httpx.Client(timeout=config.AI_TIMEOUT) as client:
|
||||||
|
resp = client.post(f"{base}/chat/completions", json=payload, headers=headers)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
content = data["choices"][0]["message"]["content"]
|
||||||
|
parsed = _extract_json(content)
|
||||||
|
parsed.setdefault("updates", [])
|
||||||
|
parsed.setdefault("risks", [])
|
||||||
|
parsed.setdefault("next_steps", [])
|
||||||
|
parsed["model"] = s.get("ai_model")
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def parse_custom(
|
||||||
|
raw: str, project_name: str, period_label: str, stage: str, db, use_ai: bool = True
|
||||||
|
) -> tuple[dict, str, str | None]:
|
||||||
|
if use_ai and ai_enabled(db):
|
||||||
|
try:
|
||||||
|
return llm_parse_custom(raw, project_name, period_label, stage, db), "ai", None
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
r = rule_parse_custom(raw, project_name)
|
||||||
|
r["_warning"] = f"AI 调用失败,已使用本地规则解析:{exc}"
|
||||||
|
return r, "rule", str(exc)
|
||||||
|
r = rule_parse_custom(raw, project_name)
|
||||||
|
if not ai_enabled(db):
|
||||||
|
r["_warning"] = "未配置大模型 API,当前使用本地规则解析(可在「设置」中填写 API 后切换到 AI 整理)。"
|
||||||
|
return r, "rule", None
|
||||||
|
|
||||||
|
|
||||||
|
def parse(raw: str, project_name: str, period_label: str, db, use_ai: bool = True) -> tuple[dict, str, str | None]:
|
||||||
|
"""返回 (解析结果, 模式, 错误信息)。"""
|
||||||
|
if use_ai and ai_enabled(db):
|
||||||
|
try:
|
||||||
|
return llm_parse(raw, project_name, period_label, db), "ai", None
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
fallback = rule_parse(raw, project_name)
|
||||||
|
fallback["_warning"] = f"AI 调用失败,已使用本地规则解析:{exc}"
|
||||||
|
return fallback, "rule", str(exc)
|
||||||
|
result = rule_parse(raw, project_name)
|
||||||
|
if not ai_enabled(db):
|
||||||
|
result["_warning"] = "未配置大模型 API,当前使用本地规则解析(可在「设置」中填写 API 后切换到 AI 整理)。"
|
||||||
|
return result, "rule", None
|
||||||
|
|
@ -0,0 +1,54 @@
|
||||||
|
"""全局配置。
|
||||||
|
|
||||||
|
数据库默认使用 SQLite,迁移 MySQL 只需修改 DATABASE_URL:
|
||||||
|
mysql+pymysql://user:pwd@host:3306/unis_board?charset=utf8mb4
|
||||||
|
其余代码无需改动(SQLAlchemy 负责方言差异)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
DATA_DIR = BASE_DIR / "data"
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
UPLOAD_DIR = DATA_DIR / "uploads"
|
||||||
|
UPLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_dotenv() -> None:
|
||||||
|
path = BASE_DIR / ".env"
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
for line in path.read_text(encoding="utf-8").splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#") or "=" not in line:
|
||||||
|
continue
|
||||||
|
key, value = line.split("=", 1)
|
||||||
|
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
|
||||||
|
|
||||||
|
|
||||||
|
_load_dotenv()
|
||||||
|
|
||||||
|
DATABASE_URL = os.getenv("DATABASE_URL", f"sqlite:///{DATA_DIR / 'board.db'}")
|
||||||
|
|
||||||
|
AI_API_BASE = os.getenv("AI_API_BASE", "https://api.deepseek.com/v1").rstrip("/")
|
||||||
|
AI_API_KEY = os.getenv("AI_API_KEY", "")
|
||||||
|
AI_MODEL = os.getenv("AI_MODEL", "deepseek-chat")
|
||||||
|
AI_TEMPERATURE = float(os.getenv("AI_TEMPERATURE", "0.2"))
|
||||||
|
AI_TIMEOUT = float(os.getenv("AI_TIMEOUT", "90"))
|
||||||
|
|
||||||
|
PORT = int(os.getenv("PORT", "8770"))
|
||||||
|
|
||||||
|
# DEV=1 启动时开启开发模式(代码热加载 + 浏览器自动刷新)
|
||||||
|
DEV = os.getenv("DEV", "").strip().lower() in ("1", "true", "yes", "on")
|
||||||
|
|
||||||
|
# 允许在页面上被覆盖的 AI 配置键
|
||||||
|
AI_SETTING_KEYS = ("ai_api_base", "ai_api_key", "ai_model", "ai_temperature")
|
||||||
|
|
||||||
|
DEFAULT_AI_SETTINGS = {
|
||||||
|
"ai_api_base": AI_API_BASE,
|
||||||
|
"ai_api_key": AI_API_KEY,
|
||||||
|
"ai_model": AI_MODEL,
|
||||||
|
"ai_temperature": str(AI_TEMPERATURE),
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,402 @@
|
||||||
|
"""状态 / 分类常量(前后端共用语义)。"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
# (code, 中文名, 主题色)
|
||||||
|
STATUSES = [
|
||||||
|
("not_started", "未开始", "#94a3b8"),
|
||||||
|
("in_progress", "进行中", "#2563eb"),
|
||||||
|
("done", "已完成", "#16a34a"),
|
||||||
|
("at_risk", "风险", "#d97706"),
|
||||||
|
("blocked", "阻塞", "#dc2626"),
|
||||||
|
("paused", "暂停/终止", "#6b7280"),
|
||||||
|
]
|
||||||
|
|
||||||
|
STATUS_MAP = {code: (name, color) for code, name, color in STATUSES}
|
||||||
|
STATUS_CODES = [c for c, _, _ in STATUSES]
|
||||||
|
|
||||||
|
NEGATIVE_STATUS = ("at_risk", "blocked", "paused")
|
||||||
|
|
||||||
|
# 项目优先级
|
||||||
|
PRIORITIES = [
|
||||||
|
("p0", "P0 最高", "#dc2626"),
|
||||||
|
("p1", "P1 高", "#d97706"),
|
||||||
|
("p2", "P2 中", "#2563eb"),
|
||||||
|
("p3", "P3 低", "#6b7280"),
|
||||||
|
]
|
||||||
|
PRIORITY_MAP = {code: (name, color) for code, name, color in PRIORITIES}
|
||||||
|
|
||||||
|
# 台账类别(来自《项目明细表》)
|
||||||
|
LEDGER_CATEGORIES = [
|
||||||
|
"新增项目",
|
||||||
|
"重点项目",
|
||||||
|
"验收项目",
|
||||||
|
"交付项目",
|
||||||
|
"提前交付",
|
||||||
|
"异常项目",
|
||||||
|
]
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 定制项目(交付制,与平台产品类项目分开管理)
|
||||||
|
# (code, 名称, 颜色, 收入确认基准比例)
|
||||||
|
# 开发中阶段:base + 0.4 * progress/100
|
||||||
|
# 其余阶段:直接使用 base
|
||||||
|
# 手动比例 manual_ratio 非空时优先使用
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
CUSTOM_STAGES = [
|
||||||
|
("lead", "商机/报备", "#94a3b8", 0.00),
|
||||||
|
("signed", "已签单/下单", "#0891b2", 0.10),
|
||||||
|
("developing", "开发中", "#2563eb", 0.10),
|
||||||
|
("delivered", "交付完成", "#7c3aed", 0.70),
|
||||||
|
("accepted", "已验收", "#d97706", 0.90),
|
||||||
|
("revenue", "已计收", "#16a34a", 1.00),
|
||||||
|
("paused", "暂停/异常", "#dc2626", 0.30),
|
||||||
|
("closed", "已关闭", "#6b7280", 1.00),
|
||||||
|
]
|
||||||
|
CUSTOM_STAGE_MAP = {c: (n, col, r) for c, n, col, r in CUSTOM_STAGES}
|
||||||
|
CUSTOM_STAGE_CODES = [c for c, _, _, _ in CUSTOM_STAGES]
|
||||||
|
# 阶段先后顺序(用于看板列排序与阶段流转判断)
|
||||||
|
CUSTOM_STAGE_ORDER = {c: i for i, (c, _, _, _) in enumerate(CUSTOM_STAGES)}
|
||||||
|
|
||||||
|
|
||||||
|
def custom_revenue_ratio(stage: str, progress: float | None, manual_ratio=None) -> float:
|
||||||
|
"""按阶段 + 开发进度计算收入确认比例(0~1)。"""
|
||||||
|
if manual_ratio is not None:
|
||||||
|
try:
|
||||||
|
return max(0.0, min(1.0, float(manual_ratio)))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
base = CUSTOM_STAGE_MAP.get(stage, ("", "#94a3b8", 0.0))[2]
|
||||||
|
if stage == "developing":
|
||||||
|
p = float(progress or 0)
|
||||||
|
return round(min(1.0, base + 0.4 * (p / 100.0)), 4)
|
||||||
|
return round(base, 4)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 定制项目:文本 → 阶段 / 金额 识别
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
CUSTOM_STAGE_RULES = [
|
||||||
|
(("暂停", "终止", "未达成一致", "搁置"), "paused"),
|
||||||
|
(("已计收", "已核销", "完成计收", "计收完成"), "revenue"),
|
||||||
|
(("验收报告", "已验收", "完成验收", "待验收"), "accepted"),
|
||||||
|
(("交付完成", "已交付", "需求交付完成"), "delivered"),
|
||||||
|
(("开发中", "开发交付中", "启动开发", "开发进行"), "developing"),
|
||||||
|
(("已下单", "已报单", "已签订", "合同签订", "合同已签"), "signed"),
|
||||||
|
(("商机", "报备", "投标"), "lead"),
|
||||||
|
]
|
||||||
|
|
||||||
|
CUSTOM_CAT_STAGE = {
|
||||||
|
"验收项目": "accepted",
|
||||||
|
"交付项目": "delivered",
|
||||||
|
"提前交付": "delivered",
|
||||||
|
"异常项目": "paused",
|
||||||
|
"新增项目": "signed",
|
||||||
|
"重点项目": "developing",
|
||||||
|
}
|
||||||
|
|
||||||
|
_CUSTOM_AMT_TAIL = re.compile(r"[((]\s*([0-9][0-9,,]*\.?[0-9]*)\s*[))]?\s*$")
|
||||||
|
_CUSTOM_WS = re.compile(r"\s+")
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_custom_name(name: str) -> tuple[str, float | None]:
|
||||||
|
"""『龙江交投高速大模型分析项目(39,400.00 )』→ ('龙江交投高速大模型分析项目', 39400.0)"""
|
||||||
|
s = _CUSTOM_WS.sub(" ", (name or "").strip())
|
||||||
|
m = _CUSTOM_AMT_TAIL.search(s)
|
||||||
|
amount = None
|
||||||
|
if m:
|
||||||
|
try:
|
||||||
|
amount = float(m.group(1).replace(",", "").replace(",", ""))
|
||||||
|
except ValueError:
|
||||||
|
amount = None
|
||||||
|
s = s[: m.start()].strip().strip("(( ")
|
||||||
|
return s.strip(" 、-—"), amount
|
||||||
|
|
||||||
|
|
||||||
|
_PROGRESS_RE = re.compile(r"(\d{1,3}(?:\.\d+)?)\s*%")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_percent(text: str | None) -> int | None:
|
||||||
|
"""取文本中最大的百分比数值(0-100)。"""
|
||||||
|
nums = [float(x) for x in _PROGRESS_RE.findall(text or "")]
|
||||||
|
nums = [n for n in nums if 0 <= n <= 100]
|
||||||
|
return int(round(max(nums))) if nums else None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 周期标签解析(『9月第1周』『8月第四周』『2025/08-第1周』)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
_CN_NUM = {"一": 1, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9, "十": 10}
|
||||||
|
_PERIOD_LABEL_RE = re.compile(
|
||||||
|
r"(?:(\d{4})\s*[/\-年]\s*)?(\d{1,2})\s*月?\s*[-—~~至]?\s*第?\s*([一二三四五六七八九十]{1,2}|\d{1,2})\s*周"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_period_label(label: str, year_hint: int = 0) -> tuple[int, int, int] | None:
|
||||||
|
"""解析周期标签 → (year, month, week)。
|
||||||
|
|
||||||
|
year_hint 为 0 表示调用方希望区分『标签里到底有没有写年份』:
|
||||||
|
没写时返回的 year 就是 0,由调用方决定用什么年份。
|
||||||
|
"""
|
||||||
|
m = _PERIOD_LABEL_RE.search(str(label or ""))
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
year = int(m.group(1)) if m.group(1) else int(year_hint or 0)
|
||||||
|
month = int(m.group(2))
|
||||||
|
w = m.group(3)
|
||||||
|
if w.isdigit():
|
||||||
|
week = int(w)
|
||||||
|
elif w == "十":
|
||||||
|
week = 10
|
||||||
|
elif w.startswith("十"):
|
||||||
|
week = 10 + _CN_NUM.get(w[1:], 0)
|
||||||
|
elif w.endswith("十"):
|
||||||
|
week = _CN_NUM.get(w[:-1], 1) * 10
|
||||||
|
else:
|
||||||
|
week = _CN_NUM.get(w, 1)
|
||||||
|
return year, month, week
|
||||||
|
|
||||||
|
|
||||||
|
def guess_custom_stage(text: str | None, category: str | None = None) -> str:
|
||||||
|
"""按『负面 → 完成 → 在制 → 商务』的顺序判定阶段。
|
||||||
|
|
||||||
|
关键点:文本里同时出现『已计收』和『进度 60%』时,应判定为开发中,
|
||||||
|
计收金额另由 revenue_delta 字段承载。
|
||||||
|
"""
|
||||||
|
t = text or ""
|
||||||
|
if any(w in t for w in ("暂停", "终止", "未达成一致", "搁置")):
|
||||||
|
return "paused"
|
||||||
|
if any(w in t for w in ("验收报告", "已验收", "完成验收")):
|
||||||
|
return "accepted"
|
||||||
|
if any(w in t for w in ("交付完成", "已交付", "需求交付完成")):
|
||||||
|
return "delivered"
|
||||||
|
|
||||||
|
pct = extract_percent(t)
|
||||||
|
in_progress_words = ("进度", "开发", "联调", "部署", "实施", "测试中", "未完成", "待完成")
|
||||||
|
if pct is not None and pct < 100 and any(w in t for w in in_progress_words):
|
||||||
|
return "developing"
|
||||||
|
|
||||||
|
for words, code in CUSTOM_STAGE_RULES:
|
||||||
|
if any(w in t for w in words):
|
||||||
|
return code
|
||||||
|
|
||||||
|
if pct is not None and pct < 100:
|
||||||
|
return "developing"
|
||||||
|
if category and category in CUSTOM_CAT_STAGE:
|
||||||
|
return CUSTOM_CAT_STAGE[category]
|
||||||
|
return "signed"
|
||||||
|
|
||||||
|
|
||||||
|
# 省/直辖市 → 下辖市县关键词(用于从项目名反推区域)
|
||||||
|
REGION_KEYWORDS: list[tuple[str, list[str]]] = [
|
||||||
|
("重庆", ["重庆", "渝北", "渝中", "南岸", "江北", "巴南", "黔江", "青白江", "天宫殿", "两江", "大足", "綦江", "长寿", "垫江"]),
|
||||||
|
("四川", ["四川", "成都", "乐山", "德阳", "荥经", "简阳", "名山", "绵阳", "宜宾", "泸州", "广元", "眉山", "资阳", "内江", "自贡", "攀枝花", "达州", "南充", "遂宁", "广安", "雅安", "凉山", "宣汉", "沙湾"]),
|
||||||
|
("内蒙古", ["内蒙古", "内蒙", "呼和浩特", "赤峰", "包头", "鄂尔多斯", "呼伦贝尔", "兴安盟", "阿拉善", "巴彦淖尔", "通辽", "乌兰察布", "锡林郭勒", "乌海", "满洲里", "杭锦旗"]),
|
||||||
|
("江苏", ["江苏", "南京", "南通", "苏州", "无锡", "常州", "徐州", "扬州", "镇江", "盐城", "淮安", "连云港", "泰州", "宿迁", "昆山", "花桥", "江宁", "通州湾"]),
|
||||||
|
("山东", ["山东", "青岛", "济南", "烟台", "潍坊", "临沂", "淄博", "济宁", "威海", "东营", "泰安", "德州", "聊城", "菏泽", "枣庄", "日照", "滨州"]),
|
||||||
|
("黑龙江", ["黑龙江", "哈尔滨", "大庆", "齐齐哈尔", "牡丹江", "佳木斯", "绥化", "鸡西", "双鸭山", "伊春", "七台河", "鹤岗", "黑河", "抚远", "龙江"]),
|
||||||
|
("吉林", ["吉林", "长春", "四平", "辽源", "通化", "白山", "松原", "白城", "延边"]),
|
||||||
|
("辽宁", ["辽宁", "沈阳", "大连", "鞍山", "抚顺", "本溪", "丹东", "锦州", "营口", "阜新", "辽阳", "盘锦", "铁岭", "朝阳", "葫芦岛"]),
|
||||||
|
("甘肃", ["甘肃", "兰州", "金昌", "天水", "白银", "酒泉", "张掖", "武威", "定西", "平凉", "庆阳", "陇南"]),
|
||||||
|
("青海", ["青海", "西宁", "海东", "格尔木", "化隆"]),
|
||||||
|
("宁夏", ["宁夏", "银川", "石嘴山", "吴忠", "固原", "中卫"]),
|
||||||
|
("陕西", ["陕西", "西安", "咸阳", "宝鸡", "渭南", "延安", "汉中", "榆林", "安康", "商洛"]),
|
||||||
|
("广西", ["广西", "南宁", "柳州", "桂林", "梧州", "北海", "贺州", "玉林", "百色", "河池", "来宾", "崇左", "钦州", "贵港", "防城港"]),
|
||||||
|
("云南", ["云南", "昆明", "曲靖", "玉溪", "昭通", "楚雄", "红河", "文山", "普洱", "大理", "丽江", "临沧", "林草", "滇东南"]),
|
||||||
|
("江西", ["江西", "南昌", "赣州", "九江", "上饶", "宜春", "吉安", "抚州", "景德镇", "萍乡", "新余", "鹰潭", "洪都"]),
|
||||||
|
("湖南", ["湖南", "长沙", "株洲", "湘潭", "衡阳", "邵阳", "岳阳", "常德", "张家界", "益阳", "郴州", "永州", "怀化", "娄底"]),
|
||||||
|
("湖北", ["湖北", "武汉", "黄石", "襄阳", "宜昌", "荆州", "十堰", "孝感", "荆门", "鄂州", "黄冈", "咸宁", "随州"]),
|
||||||
|
("河南", ["河南", "郑州", "开封", "洛阳", "平顶山", "安阳", "鹤壁", "新乡", "焦作", "濮阳", "许昌", "漯河", "三门峡", "南阳", "商丘", "信阳", "周口", "驻马店"]),
|
||||||
|
("广东", ["广东", "广州", "深圳", "东莞", "佛山", "珠海", "中山", "惠州", "汕头", "江门", "湛江", "肇庆", "清远", "潮州", "揭阳", "韶关", "梅州", "汕尾", "河源", "阳江", "茂名"]),
|
||||||
|
("北京", ["北京"]),
|
||||||
|
("天津", ["天津", "滨海"]),
|
||||||
|
("上海", ["上海", "浦东"]),
|
||||||
|
("浙江", ["浙江", "杭州", "宁波", "温州", "嘉兴", "湖州", "绍兴", "金华", "衢州", "舟山", "台州", "丽水"]),
|
||||||
|
("福建", ["福建", "福州", "厦门", "泉州", "漳州", "莆田", "三明", "南平", "龙岩", "宁德"]),
|
||||||
|
("安徽", ["安徽", "合肥", "芜湖", "蚌埠", "淮南", "马鞍山", "安庆", "黄山", "阜阳", "宿州", "六安", "亳州", "池州", "宣城", "铜陵", "滁州"]),
|
||||||
|
("贵州", ["贵州", "贵阳", "遵义", "六盘水", "安顺", "毕节", "铜仁"]),
|
||||||
|
("山西", ["山西", "太原", "大同", "阳泉", "长治", "晋城", "朔州", "晋中", "运城", "忻州", "临汾", "吕梁"]),
|
||||||
|
("河北", ["河北", "石家庄", "唐山", "秦皇岛", "邯郸", "邢台", "保定", "张家口", "承德", "沧州", "廊坊", "衡水", "雄安"]),
|
||||||
|
("新疆", ["新疆", "乌鲁木齐", "克拉玛依", "喀什", "伊犁"]),
|
||||||
|
("西藏", ["西藏", "拉萨"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def infer_region(name: str | None) -> str | None:
|
||||||
|
"""从项目名称反推所属区域(省/直辖市)。"""
|
||||||
|
s = name or ""
|
||||||
|
for region, keys in REGION_KEYWORDS:
|
||||||
|
for k in keys:
|
||||||
|
if k in s:
|
||||||
|
return region
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 规范的省级行政区列表(用于下拉选择与筛选)
|
||||||
|
# 采用与历史数据一致的简称写法,避免不必要的迁移
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
REGION_GROUPS: list[tuple[str, list[str]]] = [
|
||||||
|
("华北", ["北京", "天津", "河北", "山西", "内蒙古"]),
|
||||||
|
("东北", ["辽宁", "吉林", "黑龙江"]),
|
||||||
|
("华东", ["上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "台湾"]),
|
||||||
|
("华中", ["河南", "湖北", "湖南"]),
|
||||||
|
("华南", ["广东", "广西", "海南", "香港", "澳门"]),
|
||||||
|
("西南", ["重庆", "四川", "贵州", "云南", "西藏"]),
|
||||||
|
("西北", ["陕西", "甘肃", "青海", "宁夏", "新疆"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
ALL_REGIONS: list[str] = [r for _, lst in REGION_GROUPS for r in lst]
|
||||||
|
|
||||||
|
# 非规范写法 → 规范值
|
||||||
|
REGION_ALIAS = {
|
||||||
|
"内蒙古自治区": "内蒙古",
|
||||||
|
"广西壮族自治区": "广西",
|
||||||
|
"宁夏回族自治区": "宁夏",
|
||||||
|
"新疆维吾尔自治区": "新疆",
|
||||||
|
"西藏自治区": "西藏",
|
||||||
|
"北京市": "北京",
|
||||||
|
"天津市": "天津",
|
||||||
|
"上海市": "上海",
|
||||||
|
"重庆市": "重庆",
|
||||||
|
"未标注": None,
|
||||||
|
"": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_region(value: str | None) -> str | None:
|
||||||
|
"""把区域值规范到标准列表。"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
v = str(value).strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
if v in ALL_REGIONS:
|
||||||
|
return v
|
||||||
|
if v in REGION_ALIAS:
|
||||||
|
return REGION_ALIAS[v]
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 周期 → 日期(用于推算「入库时间」)
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
def month_week1(year: int, month: int):
|
||||||
|
"""该月第 1 周的周一。
|
||||||
|
|
||||||
|
口径:周一到周日算一周,一周归属『它的周四落在哪个月』;
|
||||||
|
第一个周四落在本月的周就是该月第 1 周(等价于「这一周至少 4 天在本月」)。
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
first = datetime.date(int(year), int(month), 1)
|
||||||
|
first_thu = first + datetime.timedelta(days=(3 - first.weekday()) % 7)
|
||||||
|
return first_thu - datetime.timedelta(days=3)
|
||||||
|
|
||||||
|
|
||||||
|
def period_first_day(year: int, month: int, week: int | None = None) -> str:
|
||||||
|
"""取该周期第一天的日期。
|
||||||
|
|
||||||
|
规则:第 1 周 = 第一个周四落在本月的那一周(见 month_week1),
|
||||||
|
第 N 周在其基础上顺延 7×(N-1) 天。week 为空或 0 时直接取该月 1 日。
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
if not week:
|
||||||
|
return datetime.date(int(year), int(month), 1).isoformat()
|
||||||
|
day = month_week1(year, month) + datetime.timedelta(days=(max(int(week), 1) - 1) * 7)
|
||||||
|
return day.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def week_of_date(value) -> tuple[int, int, int]:
|
||||||
|
"""日期 → 该日所属的 (年, 月, 第几周),与 period_first_day 互为逆运算。"""
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
d = datetime.date.fromisoformat(str(value)[:10]) if isinstance(value, str) else value
|
||||||
|
monday = d - datetime.timedelta(days=d.weekday())
|
||||||
|
thursday = monday + datetime.timedelta(days=3) # 周四在哪个月,这一周就算哪个月的
|
||||||
|
year, month = thursday.year, thursday.month
|
||||||
|
return year, month, (monday - month_week1(year, month)).days // 7 + 1
|
||||||
|
|
||||||
|
|
||||||
|
def period_last_day(year: int, month: int, week: int | None = None) -> str:
|
||||||
|
"""取该周期最后一天的日期。"""
|
||||||
|
import calendar
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
if not week:
|
||||||
|
last = calendar.monthrange(int(year), int(month))[1]
|
||||||
|
return datetime.date(int(year), int(month), last).isoformat()
|
||||||
|
d = datetime.date.fromisoformat(period_first_day(year, month, week))
|
||||||
|
return (d + datetime.timedelta(days=6)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# 默认分类配色
|
||||||
|
DEFAULT_CATEGORY_COLORS = [
|
||||||
|
"#2563eb", "#7c3aed", "#0891b2", "#16a34a",
|
||||||
|
"#d97706", "#dc2626", "#db2777", "#0d9488",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 定制项目:全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
|
||||||
|
# key 环节代码(与 CUSTOM_STAGES 的阶段代码对齐,入库单独用 entry)
|
||||||
|
# name 环节名
|
||||||
|
# date_field 该环节的日期字段(CustomProject 上的属性名)
|
||||||
|
# stage 完成该环节后项目所处的阶段
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
CUSTOM_PIPELINE = [
|
||||||
|
("entry", "入库", "entry_date", "lead", "#94a3b8"),
|
||||||
|
("signed", "签单", "sign_date", "signed", "#0891b2"),
|
||||||
|
("delivered", "交付", "deliver_date", "delivered", "#7c3aed"),
|
||||||
|
("accepted", "验收", "accept_date", "accepted", "#d97706"),
|
||||||
|
("revenue", "计收", "revenue_date", "revenue", "#16a34a"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# 「在途」阶段:还没走完 入库→签单→交付→验收 这条主线(已计收 / 暂停 / 已关闭不算在途)
|
||||||
|
CUSTOM_INFLIGHT_STAGES = {"lead", "signed", "developing", "delivered", "accepted"}
|
||||||
|
|
||||||
|
# 环节顺序索引,用于判断项目当前卡在哪一环
|
||||||
|
CUSTOM_STEP_ORDER = {key: i for i, (key, _, _, _, _) in enumerate(CUSTOM_PIPELINE)}
|
||||||
|
|
||||||
|
# 环节停滞天数超过该值视为「卡住」,需要在看板上提示
|
||||||
|
CUSTOM_STEP_AGING_DAYS = 45
|
||||||
|
|
||||||
|
|
||||||
|
def custom_current_step(dates: dict[str, str | None]) -> int:
|
||||||
|
"""按已完成的环节日期,返回项目当前所处的环节下标(-1 表示连入库都没有)。"""
|
||||||
|
step = -1
|
||||||
|
for i, (_key, _name, field, _stage, _color) in enumerate(CUSTOM_PIPELINE):
|
||||||
|
if (dates.get(field) or "").strip():
|
||||||
|
step = i
|
||||||
|
return step
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 人力资源库
|
||||||
|
# --------------------------------------------------------------------------
|
||||||
|
# 人员归属(用工主体)
|
||||||
|
STAFF_AFFILIATIONS = ["汇智", "云数", "外服"]
|
||||||
|
|
||||||
|
# 归属小组(标准口径)
|
||||||
|
STAFF_GROUPS = ["管理", "Nex产品组", "定制产品组", "AI产品组", "综合"]
|
||||||
|
|
||||||
|
# 历史数据里的小组别名 → 标准口径
|
||||||
|
STAFF_GROUP_ALIAS = {"AI项目组": "AI产品组"}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_staff_group(value: str | None) -> str | None:
|
||||||
|
v = (value or "").strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
return STAFF_GROUP_ALIAS.get(v, v)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_staff_affiliation(value: str | None) -> str | None:
|
||||||
|
v = (value or "").strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
for a in STAFF_AFFILIATIONS:
|
||||||
|
if a in v:
|
||||||
|
return a
|
||||||
|
return v
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, sessionmaker
|
||||||
|
|
||||||
|
from .config import DATABASE_URL
|
||||||
|
|
||||||
|
_is_sqlite = DATABASE_URL.startswith("sqlite")
|
||||||
|
connect_args = {"check_same_thread": False} if _is_sqlite else {}
|
||||||
|
|
||||||
|
engine = create_engine(
|
||||||
|
DATABASE_URL,
|
||||||
|
connect_args=connect_args,
|
||||||
|
pool_pre_ping=not _is_sqlite,
|
||||||
|
future=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
"""所有模型的基类。"""
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
@ -0,0 +1,105 @@
|
||||||
|
"""Excel 导出。
|
||||||
|
|
||||||
|
统一用 openpyxl 生成带格式的工作簿:表头加粗底色、冻结首行、自动筛选、列宽自适应。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Any, Iterable, Sequence
|
||||||
|
from urllib.parse import quote
|
||||||
|
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
HEAD_FILL = PatternFill("solid", fgColor="EEF2F8")
|
||||||
|
HEAD_FONT = Font(bold=True, color="1F2430", size=11)
|
||||||
|
TITLE_FONT = Font(bold=True, size=14)
|
||||||
|
THIN = Side(style="thin", color="D8DEE8")
|
||||||
|
BORDER = Border(left=THIN, right=THIN, top=THIN, bottom=THIN)
|
||||||
|
NUM_FMT = "#,##0.00"
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_width(value: Any) -> int:
|
||||||
|
"""按中文字符占 2 个宽度估算显示宽度。"""
|
||||||
|
s = "" if value is None else str(value)
|
||||||
|
w = 0
|
||||||
|
for ch in s:
|
||||||
|
w += 2 if ord(ch) > 127 else 1
|
||||||
|
return w
|
||||||
|
|
||||||
|
|
||||||
|
def build_xlsx(
|
||||||
|
sheet_name: str,
|
||||||
|
headers: Sequence[str],
|
||||||
|
rows: Iterable[Sequence[Any]],
|
||||||
|
widths: Sequence[int] | None = None,
|
||||||
|
title: str | None = None,
|
||||||
|
money_cols: Sequence[int] = (),
|
||||||
|
) -> bytes:
|
||||||
|
"""生成 xlsx 字节流。
|
||||||
|
|
||||||
|
money_cols 中的列索引按 1 开始计(对应 headers 的下标 + 1)。
|
||||||
|
"""
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = sheet_name[:31] or "Sheet1"
|
||||||
|
rows = list(rows)
|
||||||
|
|
||||||
|
offset = 0
|
||||||
|
if title:
|
||||||
|
ws.cell(row=1, column=1, value=title).font = TITLE_FONT
|
||||||
|
ws.cell(row=2, column=1, value=f"导出时间:{datetime.now():%Y-%m-%d %H:%M}")
|
||||||
|
ws.cell(row=2, column=1).font = Font(size=9, color="7A8394")
|
||||||
|
offset = 2
|
||||||
|
|
||||||
|
head_row = offset + 1
|
||||||
|
for c, h in enumerate(headers, 1):
|
||||||
|
cell = ws.cell(row=head_row, column=c, value=h)
|
||||||
|
cell.fill = HEAD_FILL
|
||||||
|
cell.font = HEAD_FONT
|
||||||
|
cell.border = BORDER
|
||||||
|
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||||||
|
|
||||||
|
for r, row in enumerate(rows, head_row + 1):
|
||||||
|
for c, v in enumerate(row, 1):
|
||||||
|
cell = ws.cell(row=r, column=c, value=v)
|
||||||
|
cell.border = BORDER
|
||||||
|
cell.alignment = Alignment(vertical="top", wrap_text=False)
|
||||||
|
if c in money_cols and isinstance(v, (int, float)):
|
||||||
|
cell.number_format = NUM_FMT
|
||||||
|
|
||||||
|
# 列宽:显式指定优先,否则按内容估算
|
||||||
|
n_cols = len(headers)
|
||||||
|
for c in range(1, n_cols + 1):
|
||||||
|
if widths and c <= len(widths):
|
||||||
|
width = widths[c - 1]
|
||||||
|
else:
|
||||||
|
longest = _cell_width(headers[c - 1])
|
||||||
|
for row in rows:
|
||||||
|
if c <= len(row):
|
||||||
|
longest = max(longest, _cell_width(row[c - 1]))
|
||||||
|
width = min(max(longest + 4, 10), 60)
|
||||||
|
ws.column_dimensions[get_column_letter(c)].width = width
|
||||||
|
|
||||||
|
ws.freeze_panes = ws.cell(row=head_row + 1, column=1)
|
||||||
|
ws.auto_filter.ref = (
|
||||||
|
f"A{head_row}:{get_column_letter(n_cols)}{head_row + max(len(rows), 1)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def xlsx_response(content: bytes, filename: str) -> tuple[bytes, dict]:
|
||||||
|
"""返回 (内容, 响应头),中文文件名用 RFC 5987 编码。"""
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d_%H%M")
|
||||||
|
full = f"{filename}_{stamp}.xlsx"
|
||||||
|
headers = {
|
||||||
|
"Content-Disposition": f"attachment; filename=\"{quote(full)}\"; filename*=UTF-8''{quote(full)}",
|
||||||
|
"Content-Type": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
"Access-Control-Expose-Headers": "Content-Disposition",
|
||||||
|
}
|
||||||
|
return content, headers
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
|
||||||
|
from . import config
|
||||||
|
from .db import Base, engine
|
||||||
|
from .routers import ai, board, custom, export, focus, projects, staff, weekly
|
||||||
|
from .schema_patch import ensure_columns
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_patch = ensure_columns(engine, Base.metadata)
|
||||||
|
if _patch:
|
||||||
|
print(f"[ok] 已补齐数据库字段:{', '.join(_patch)}")
|
||||||
|
|
||||||
|
# 每次进程启动换一个值;热加载重启后前端据此自动刷新
|
||||||
|
BOOT_ID = uuid.uuid4().hex
|
||||||
|
|
||||||
|
app = FastAPI(title="部门关键任务追踪看板", version="1.0.0")
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=["*"],
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
api = FastAPI(title="部门关键任务追踪看板 API")
|
||||||
|
api.include_router(board.router, prefix="", tags=["board"])
|
||||||
|
api.include_router(projects.router, prefix="", tags=["projects"])
|
||||||
|
api.include_router(ai.router, prefix="", tags=["ai"])
|
||||||
|
api.include_router(custom.router, prefix="", tags=["custom"])
|
||||||
|
api.include_router(export.router, prefix="", tags=["export"])
|
||||||
|
api.include_router(staff.router, prefix="", tags=["staff"])
|
||||||
|
api.include_router(weekly.router, prefix="", tags=["weekly"])
|
||||||
|
api.include_router(focus.router, prefix="", tags=["focus"])
|
||||||
|
app.mount("/api", api)
|
||||||
|
|
||||||
|
WEB_DIR = config.BASE_DIR / "web"
|
||||||
|
app.mount("/static", StaticFiles(directory=str(WEB_DIR)), name="static")
|
||||||
|
|
||||||
|
|
||||||
|
# 前端零构建、文件名不带版本号,浏览器会启发式缓存 index.html / app.js / styles.css,
|
||||||
|
# 改完界面看不到新版。no-cache 不做新鲜度缓存、只带 ETag 回源校验(未变更时返回 304)。
|
||||||
|
@app.middleware("http")
|
||||||
|
async def no_cache_frontend(request, call_next):
|
||||||
|
response = await call_next(request)
|
||||||
|
if request.url.path == "/" or request.url.path.startswith("/static/"):
|
||||||
|
response.headers["Cache-Control"] = "no-cache"
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
def health():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"db": config.DATABASE_URL.split("://")[0],
|
||||||
|
"dev": config.DEV,
|
||||||
|
"boot": BOOT_ID,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/")
|
||||||
|
def index():
|
||||||
|
return FileResponse(str(WEB_DIR / "index.html"))
|
||||||
|
|
@ -0,0 +1,580 @@
|
||||||
|
"""数据模型。
|
||||||
|
|
||||||
|
层级:Period(周期) -> Project(主要项目/工作项) -> Task(子任务) -> Update(执行情况)
|
||||||
|
辅助:Category(分类) / Tag(标记) / LedgerItem(客户项目台账) / Setting(配置)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Column,
|
||||||
|
DateTime,
|
||||||
|
Float,
|
||||||
|
ForeignKey,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Table,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
from .db import Base
|
||||||
|
|
||||||
|
|
||||||
|
def now():
|
||||||
|
return datetime.now()
|
||||||
|
|
||||||
|
|
||||||
|
project_tags = Table(
|
||||||
|
"project_tags",
|
||||||
|
Base.metadata,
|
||||||
|
Column("project_id", Integer, ForeignKey("projects.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
Column("tag_id", Integer, ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ 人员名单
|
||||||
|
# 多负责人 / 团队成员是「一串人名」,不建关联表:直接塞进一个字符串列,
|
||||||
|
# 首尾都带逗号(",张三,李四,"),这样才能用 LIKE '%,张三,%' 精确匹配某个人,
|
||||||
|
# 不至于让「张三」命中「张三丰」。读出来 split,写进去 join。
|
||||||
|
PEOPLE_SEP = ","
|
||||||
|
|
||||||
|
|
||||||
|
def split_people(value: str | None) -> list[str]:
|
||||||
|
"""存 → 名单。"""
|
||||||
|
return [x for x in (value or "").split(PEOPLE_SEP) if x]
|
||||||
|
|
||||||
|
|
||||||
|
def dedupe_people(names) -> list[str]:
|
||||||
|
"""清洗前端传来的名单:去空白、去重、保持顺序。"""
|
||||||
|
out: list[str] = []
|
||||||
|
for n in names or []:
|
||||||
|
n = (n or "").strip()
|
||||||
|
if n and n not in out:
|
||||||
|
out.append(n)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def join_people(names) -> str | None:
|
||||||
|
"""名单 → 存(空名单存 NULL)。"""
|
||||||
|
out = dedupe_people(names)
|
||||||
|
if not out:
|
||||||
|
return None
|
||||||
|
return f"{PEOPLE_SEP}{PEOPLE_SEP.join(out)}{PEOPLE_SEP}"
|
||||||
|
|
||||||
|
|
||||||
|
def people_like(name: str) -> str:
|
||||||
|
"""按人名匹配名单列的 LIKE 模式。"""
|
||||||
|
return f"%{PEOPLE_SEP}{(name or '').strip()}{PEOPLE_SEP}%"
|
||||||
|
|
||||||
|
|
||||||
|
def all_owners(owner: str | None, other_owners: str | None) -> list[str]:
|
||||||
|
"""主负责人 + 其他负责人,主负责人恒在第一位。"""
|
||||||
|
return dedupe_people([owner] + split_people(other_owners))
|
||||||
|
|
||||||
|
|
||||||
|
def set_people(obj, owners=None, members=None):
|
||||||
|
"""整体替换(不是增量):owners 传空数组即清空。传 None 表示这次不改。
|
||||||
|
|
||||||
|
owners 的第一位就是主负责人,写进 obj.owner —— 看板、筛选、导出、
|
||||||
|
人力资源库负载全都照旧读 owner,一个字都不用改。
|
||||||
|
"""
|
||||||
|
if owners is not None:
|
||||||
|
names = dedupe_people(owners)
|
||||||
|
obj.owner = names[0] if names else None
|
||||||
|
obj.other_owners = join_people(names[1:])
|
||||||
|
if members is not None:
|
||||||
|
obj.members = join_people(members)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime, default=now, onupdate=now, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Category(Base, TimestampMixin):
|
||||||
|
"""项目分类(如 平台产品 / AI / 定制交付 / 内部运营)。"""
|
||||||
|
|
||||||
|
__tablename__ = "categories"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(String(16), default="#2563eb", nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
projects: Mapped[list["Project"]] = relationship(back_populates="category")
|
||||||
|
|
||||||
|
def to_dict(self, with_count: bool = False):
|
||||||
|
data = {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"color": self.color,
|
||||||
|
"sort_order": self.sort_order,
|
||||||
|
}
|
||||||
|
if with_count:
|
||||||
|
data["project_count"] = len(self.projects)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class Tag(Base):
|
||||||
|
"""自由标记(如 重点 / 需支援 / 待决策)。"""
|
||||||
|
|
||||||
|
__tablename__ = "tags"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(32), unique=True, nullable=False)
|
||||||
|
color: Mapped[str] = mapped_column(String(16), default="#6b7280", nullable=False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {"id": self.id, "name": self.name, "color": self.color}
|
||||||
|
|
||||||
|
|
||||||
|
class Project(Base, TimestampMixin):
|
||||||
|
"""主要项目 / 工作项。"""
|
||||||
|
|
||||||
|
__tablename__ = "projects"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
|
||||||
|
category_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("categories.id"), nullable=True
|
||||||
|
)
|
||||||
|
owner: Mapped[str | None] = mapped_column(String(64), nullable=True) # 主负责人
|
||||||
|
other_owners: Mapped[str | None] = mapped_column(String(512), nullable=True) # 其他负责人
|
||||||
|
members: Mapped[str | None] = mapped_column(String(512), nullable=True) # 团队成员
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="in_progress", nullable=False)
|
||||||
|
priority: Mapped[str] = mapped_column(String(8), default="p2", nullable=False)
|
||||||
|
is_key: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) # 重点标记
|
||||||
|
progress: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) # 最新进度 %
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
category: Mapped[Category | None] = relationship(back_populates="projects")
|
||||||
|
tags: Mapped[list[Tag]] = relationship(secondary=project_tags, lazy="selectin")
|
||||||
|
tasks: Mapped[list["Task"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
updates: Mapped[list["Update"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self, brief: bool = True):
|
||||||
|
data = {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"category_id": self.category_id,
|
||||||
|
"category": self.category.to_dict() if self.category else None,
|
||||||
|
"owner": self.owner,
|
||||||
|
"owners": all_owners(self.owner, self.other_owners),
|
||||||
|
"members": split_people(self.members),
|
||||||
|
"status": self.status,
|
||||||
|
"priority": self.priority,
|
||||||
|
"is_key": self.is_key,
|
||||||
|
"progress": round(self.progress or 0, 1),
|
||||||
|
"description": self.description,
|
||||||
|
"archived": self.archived,
|
||||||
|
"sort_order": self.sort_order,
|
||||||
|
"tags": [t.to_dict() for t in self.tags],
|
||||||
|
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||||||
|
}
|
||||||
|
if not brief:
|
||||||
|
data["task_count"] = len(self.tasks)
|
||||||
|
data["update_count"] = len(self.updates)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class Period(Base):
|
||||||
|
"""周期(周 / 月)。"""
|
||||||
|
|
||||||
|
__tablename__ = "periods"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
label: Mapped[str] = mapped_column(String(64), nullable=False) # 8月第4周
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), default="week", nullable=False) # week|month
|
||||||
|
year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
month: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
week: Mapped[int] = mapped_column(Integer, default=0, nullable=False) # 月视图为 0
|
||||||
|
start_date: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
end_date: Mapped[str | None] = mapped_column(String(16), nullable=True)
|
||||||
|
# 排序键:year*10000 + month*100 + week
|
||||||
|
sort_key: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||||
|
|
||||||
|
updates: Mapped[list["Update"]] = relationship(back_populates="period")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"kind": self.kind,
|
||||||
|
"year": self.year,
|
||||||
|
"month": self.month,
|
||||||
|
"week": self.week,
|
||||||
|
"start_date": self.start_date,
|
||||||
|
"end_date": self.end_date,
|
||||||
|
"sort_key": self.sort_key,
|
||||||
|
"full_label": f"{self.year}年{self.month}月" + (f"第{self.week}周" if self.week else ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Task(Base, TimestampMixin):
|
||||||
|
"""项目下的子任务(对应原表『内容说明』)。"""
|
||||||
|
|
||||||
|
__tablename__ = "tasks"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
|
||||||
|
project: Mapped[Project] = relationship(back_populates="tasks")
|
||||||
|
updates: Mapped[list["Update"]] = relationship(back_populates="task")
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"name": self.name,
|
||||||
|
"sort_order": self.sort_order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Update(Base, TimestampMixin):
|
||||||
|
"""某周期下、某项目(可细化到子任务)的执行情况。"""
|
||||||
|
|
||||||
|
__tablename__ = "updates"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
project_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
task_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
period_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
content: Mapped[str | None] = mapped_column(Text, nullable=True) # 执行情况正文
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text, nullable=True) # 一句话摘要
|
||||||
|
progress: Mapped[float | None] = mapped_column(Float, nullable=True) # 该条进度 %
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="in_progress", nullable=False)
|
||||||
|
risk: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
next_step: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(16), default="manual", nullable=False) # import|ai|manual
|
||||||
|
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
project: Mapped[Project] = relationship(back_populates="updates")
|
||||||
|
task: Mapped[Task | None] = relationship(back_populates="updates")
|
||||||
|
period: Mapped[Period] = relationship(back_populates="updates")
|
||||||
|
|
||||||
|
def to_dict(self, with_project: bool = False):
|
||||||
|
data = {
|
||||||
|
"id": self.id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"task_id": self.task_id,
|
||||||
|
"task_name": self.task.name if self.task else None,
|
||||||
|
"period_id": self.period_id,
|
||||||
|
"period_label": self.period.label if self.period else None,
|
||||||
|
"period_full": self.period.to_dict()["full_label"] if self.period else None,
|
||||||
|
"content": self.content,
|
||||||
|
"summary": self.summary,
|
||||||
|
"progress": self.progress,
|
||||||
|
"status": self.status,
|
||||||
|
"risk": self.risk,
|
||||||
|
"next_step": self.next_step,
|
||||||
|
"owner": self.owner,
|
||||||
|
"source": self.source,
|
||||||
|
"model": self.model,
|
||||||
|
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
|
||||||
|
}
|
||||||
|
if with_project and self.project:
|
||||||
|
data["project_name"] = self.project.name
|
||||||
|
data["project_is_key"] = self.project.is_key
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class WeeklyFocus(Base, TimestampMixin):
|
||||||
|
"""每周重点工作:单条录入、按周跟踪的短期任务。
|
||||||
|
|
||||||
|
与 Project / Update 分开——不用先建项目档案,一条就是一个活儿。
|
||||||
|
period_id 是录入周(新建时所在的那一周,语义同 Update.period_id);
|
||||||
|
预计完成时间是日历日期(due_date),不参与按周分组;
|
||||||
|
是否延期(delayed)由人工开关,更早周次延期未完成的条目结转进当周视图。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "weekly_focus"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
content: Mapped[str] = mapped_column(String(255), nullable=False) # 工作内容
|
||||||
|
detail: Mapped[str | None] = mapped_column(Text, nullable=True) # 说明
|
||||||
|
period_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
) # 录入周(新建时所在的这一周,用于按周分组;不是预计完成时间所在的周)
|
||||||
|
due_date: Mapped[str | None] = mapped_column(String(32), nullable=True) # 预计完成时间(日历日期 YYYY-MM-DD,只作目标日期看)
|
||||||
|
result: Mapped[str | None] = mapped_column(Text, nullable=True) # 结果反馈
|
||||||
|
delayed: Mapped[bool | None] = mapped_column(Boolean, nullable=True) # 是否延期(人工开关)
|
||||||
|
status: Mapped[str] = mapped_column(String(32), default="not_started", nullable=False)
|
||||||
|
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
period: Mapped[Period] = relationship()
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"content": self.content,
|
||||||
|
"detail": self.detail,
|
||||||
|
"period_id": self.period_id,
|
||||||
|
"period_label": self.period.label if self.period else None,
|
||||||
|
"period_full": self.period.to_dict()["full_label"] if self.period else None,
|
||||||
|
"sort_key": self.period.sort_key if self.period else 0,
|
||||||
|
"due_date": self.due_date,
|
||||||
|
"result": self.result,
|
||||||
|
"delayed": bool(self.delayed),
|
||||||
|
"status": self.status,
|
||||||
|
"archived": self.archived,
|
||||||
|
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class LedgerItem(Base, TimestampMixin):
|
||||||
|
"""客户项目台账(来自《项目明细表》:区域 / 金额 / 类别 / 状态说明)。"""
|
||||||
|
|
||||||
|
__tablename__ = "ledger_items"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
region: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
amount: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
category: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
status_note: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
period_label: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
period_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("periods.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"region": self.region,
|
||||||
|
"amount": self.amount,
|
||||||
|
"category": self.category,
|
||||||
|
"status_note": self.status_note,
|
||||||
|
"period_label": self.period_label,
|
||||||
|
"period_id": self.period_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class CustomProject(Base, TimestampMixin):
|
||||||
|
"""定制交付项目(与通用研发项目完全分离)。
|
||||||
|
|
||||||
|
关注:商务阶段流转(签单 → 开发 → 交付 → 验收 → 计收)与按进度确认收入。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "custom_projects"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False, index=True)
|
||||||
|
customer: Mapped[str | None] = mapped_column(String(128), nullable=True) # 客户 / 办事处
|
||||||
|
region: Mapped[str | None] = mapped_column(String(64), nullable=True) # 区域(省/办)
|
||||||
|
amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 合同/订单金额
|
||||||
|
currency: Mapped[str] = mapped_column(String(8), default="CNY", nullable=False)
|
||||||
|
|
||||||
|
stage: Mapped[str] = mapped_column(String(32), default="signed", nullable=False, index=True)
|
||||||
|
progress: Mapped[float] = mapped_column(Float, default=0.0, nullable=False) # 开发进度 %
|
||||||
|
manual_ratio: Mapped[float | None] = mapped_column(Float, nullable=True) # 手动收入确认比例(覆盖阶段推算)
|
||||||
|
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) # 已停用:定制交付不做多负责人
|
||||||
|
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) # 签单/下单时间
|
||||||
|
deliver_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 交付时间
|
||||||
|
accept_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 验收时间
|
||||||
|
revenue_date: Mapped[str | None] = mapped_column(String(16), nullable=True) # 计收时间
|
||||||
|
|
||||||
|
invoice_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已开票金额
|
||||||
|
received_amount: Mapped[float | None] = mapped_column(Float, nullable=True) # 已回款金额
|
||||||
|
|
||||||
|
period_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("periods.id", ondelete="SET NULL"), nullable=True
|
||||||
|
) # 最近一次更新所在周期
|
||||||
|
remark: Mapped[str | None] = mapped_column(Text, nullable=True) # 最新状态说明
|
||||||
|
is_key: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
source: Mapped[str] = mapped_column(String(16), default="import", nullable=False)
|
||||||
|
|
||||||
|
updates: Mapped[list["CustomUpdate"]] = relationship(
|
||||||
|
back_populates="project", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self, with_revenue: bool = True):
|
||||||
|
from .constants import custom_revenue_ratio
|
||||||
|
|
||||||
|
ratio = custom_revenue_ratio(self.stage, self.progress, self.manual_ratio)
|
||||||
|
data = {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"customer": self.customer,
|
||||||
|
"region": self.region,
|
||||||
|
"amount": self.amount,
|
||||||
|
"stage": self.stage,
|
||||||
|
"progress": round(self.progress or 0, 1),
|
||||||
|
"manual_ratio": self.manual_ratio,
|
||||||
|
"revenue_amount": self.revenue_amount,
|
||||||
|
"owner": self.owner,
|
||||||
|
"members": split_people(self.members),
|
||||||
|
"entry_date": self.entry_date,
|
||||||
|
"sign_date": self.sign_date,
|
||||||
|
"deliver_date": self.deliver_date,
|
||||||
|
"accept_date": self.accept_date,
|
||||||
|
"revenue_date": self.revenue_date,
|
||||||
|
"invoice_amount": self.invoice_amount,
|
||||||
|
"received_amount": self.received_amount,
|
||||||
|
"period_id": self.period_id,
|
||||||
|
"remark": self.remark,
|
||||||
|
"is_key": self.is_key,
|
||||||
|
"archived": self.archived,
|
||||||
|
"source": self.source,
|
||||||
|
"update_count": len(self.updates),
|
||||||
|
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||||||
|
}
|
||||||
|
if with_revenue:
|
||||||
|
data["revenue_ratio"] = round(ratio * 100, 1)
|
||||||
|
data["confirmed_amount"] = round((self.amount or 0) * ratio, 2) # 按进度可确认收入
|
||||||
|
data["pending_amount"] = round(
|
||||||
|
(self.amount or 0) - (self.revenue_amount or 0), 2
|
||||||
|
) # 待计收
|
||||||
|
data["pending_invoice"] = round(
|
||||||
|
max(0.0, (self.amount or 0) - (self.invoice_amount or 0)), 2
|
||||||
|
)
|
||||||
|
data["pending_received"] = round(
|
||||||
|
max(0.0, (self.amount or 0) - (self.received_amount or 0)), 2
|
||||||
|
)
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUpdate(Base, TimestampMixin):
|
||||||
|
"""定制项目的阶段流转 / 周期执行记录(独立表)。"""
|
||||||
|
|
||||||
|
__tablename__ = "custom_updates"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
custom_project_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("custom_projects.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
period_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("periods.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
stage: Mapped[str | None] = mapped_column(String(32), nullable=True) # 该时点所处阶段
|
||||||
|
content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
summary: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
progress: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||||
|
amount_delta: Mapped[float | None] = mapped_column(Float, nullable=True) # 本期新增下单金额
|
||||||
|
revenue_delta: Mapped[float | None] = mapped_column(Float, nullable=True) # 本期新增计收
|
||||||
|
risk: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
next_step: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
owner: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
source: Mapped[str] = mapped_column(String(16), default="manual", nullable=False)
|
||||||
|
raw_text: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
project: Mapped[CustomProject] = relationship(back_populates="updates")
|
||||||
|
period: Mapped[Period] = relationship()
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"custom_project_id": self.custom_project_id,
|
||||||
|
"project_name": self.project.name if self.project else None,
|
||||||
|
"period_id": self.period_id,
|
||||||
|
"period_label": self.period.label if self.period else None,
|
||||||
|
"period_full": self.period.to_dict()["full_label"] if self.period else None,
|
||||||
|
"sort_key": self.period.sort_key if self.period else 0,
|
||||||
|
"stage": self.stage,
|
||||||
|
"content": self.content,
|
||||||
|
"summary": self.summary,
|
||||||
|
"progress": self.progress,
|
||||||
|
"amount_delta": self.amount_delta,
|
||||||
|
"revenue_delta": self.revenue_delta,
|
||||||
|
"risk": self.risk,
|
||||||
|
"next_step": self.next_step,
|
||||||
|
"owner": self.owner,
|
||||||
|
"source": self.source,
|
||||||
|
"model": self.model,
|
||||||
|
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Staff(Base, TimestampMixin):
|
||||||
|
"""人力资源库成员。
|
||||||
|
|
||||||
|
项目管理 / 定制交付的负责人一律从这里选取;归档后不再出现在下拉里,
|
||||||
|
历史记录中的人名保持不变。
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "staff"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
|
||||||
|
group: Mapped[str | None] = mapped_column(String(64), nullable=True) # 归属小组
|
||||||
|
affiliation: Mapped[str | None] = mapped_column(String(32), nullable=True) # 人员归属(汇智/云数/外服)
|
||||||
|
note: Mapped[str | None] = mapped_column(Text, nullable=True) # 说明 / 备注
|
||||||
|
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
archived: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"group": self.group,
|
||||||
|
"affiliation": self.affiliation,
|
||||||
|
"note": self.note,
|
||||||
|
"sort_order": self.sort_order,
|
||||||
|
"archived": self.archived,
|
||||||
|
"updated_at": self.updated_at.isoformat(timespec="seconds") if self.updated_at else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Setting(Base):
|
||||||
|
"""键值配置(AI 参数等,可在页面设置中修改)。"""
|
||||||
|
|
||||||
|
__tablename__ = "settings"
|
||||||
|
|
||||||
|
key: Mapped[str] = mapped_column(String(64), primary_key=True)
|
||||||
|
value: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportLog(Base):
|
||||||
|
"""AI 整理留痕。"""
|
||||||
|
|
||||||
|
__tablename__ = "import_logs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||||
|
project_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
period_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
raw_input: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
parsed_json: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
mode: Mapped[str] = mapped_column(String(16), default="rule", nullable=False)
|
||||||
|
model: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=now, nullable=False)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"project_id": self.project_id,
|
||||||
|
"period_id": self.period_id,
|
||||||
|
"mode": self.mode,
|
||||||
|
"model": self.model,
|
||||||
|
"raw_input": (self.raw_input or "")[:400],
|
||||||
|
"created_at": self.created_at.isoformat(timespec="seconds") if self.created_at else None,
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,144 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import ai_parser
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import ImportLog, Period, Project, Task, Update
|
||||||
|
from ..schemas import AICommitIn, AIParseIn
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_project(db: Session, project_id: int | None, project_name: str | None) -> Project | None:
|
||||||
|
if project_id:
|
||||||
|
p = db.get(Project, project_id)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "项目不存在")
|
||||||
|
return p
|
||||||
|
if project_name:
|
||||||
|
p = db.query(Project).filter(Project.name == project_name).one_or_none()
|
||||||
|
if not p:
|
||||||
|
p = Project(name=project_name)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
return p
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai/parse")
|
||||||
|
def ai_parse(payload: AIParseIn, db: Session = Depends(get_db)):
|
||||||
|
project = _resolve_project(db, payload.project_id, payload.project_name)
|
||||||
|
period = db.get(Period, payload.period_id) if payload.period_id else None
|
||||||
|
|
||||||
|
parsed, mode, error = ai_parser.parse(
|
||||||
|
payload.raw_text,
|
||||||
|
project.name if project else "",
|
||||||
|
period.label if period else "",
|
||||||
|
db,
|
||||||
|
use_ai=payload.use_ai,
|
||||||
|
)
|
||||||
|
warning = parsed.pop("_warning", None)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"error": error,
|
||||||
|
"warning": warning,
|
||||||
|
"parsed": parsed,
|
||||||
|
"project": project.to_dict() if project else None,
|
||||||
|
"period": period.to_dict() if period else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/ai/commit")
|
||||||
|
def ai_commit(payload: AICommitIn, db: Session = Depends(get_db)):
|
||||||
|
project = _resolve_project(db, payload.project_id, payload.project_name)
|
||||||
|
if not project:
|
||||||
|
raise HTTPException(400, "缺少项目信息")
|
||||||
|
period = db.get(Period, payload.period_id)
|
||||||
|
if not period:
|
||||||
|
raise HTTPException(404, "周期不存在")
|
||||||
|
|
||||||
|
parsed = payload.parsed or {}
|
||||||
|
tasks = parsed.get("tasks") or []
|
||||||
|
created = []
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
u = Update(
|
||||||
|
project_id=project.id,
|
||||||
|
period_id=period.id,
|
||||||
|
content=payload.raw_text,
|
||||||
|
summary=parsed.get("summary"),
|
||||||
|
progress=parsed.get("progress"),
|
||||||
|
status=parsed.get("status") or "in_progress",
|
||||||
|
risk=";".join(parsed.get("risks") or []) or None,
|
||||||
|
next_step=";".join(parsed.get("next_steps") or []) or None,
|
||||||
|
source="ai" if payload.mode == "ai" else "manual",
|
||||||
|
raw_text=payload.raw_text,
|
||||||
|
model=payload.model,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
created.append(u)
|
||||||
|
else:
|
||||||
|
risks = parsed.get("risks") or []
|
||||||
|
nexts = parsed.get("next_steps") or []
|
||||||
|
for idx, t in enumerate(tasks):
|
||||||
|
name = (t.get("name") or "执行事项").strip()[:255] or "执行事项"
|
||||||
|
task = (
|
||||||
|
db.query(Task)
|
||||||
|
.filter(Task.project_id == project.id, Task.name == name)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if not task:
|
||||||
|
task = Task(project_id=project.id, name=name, sort_order=idx)
|
||||||
|
db.add(task)
|
||||||
|
db.flush()
|
||||||
|
risk = (t.get("risk") or "").strip() or (risks[0] if idx == 0 and risks else None)
|
||||||
|
u = Update(
|
||||||
|
project_id=project.id,
|
||||||
|
task_id=task.id,
|
||||||
|
period_id=period.id,
|
||||||
|
content=(t.get("content") or "").strip() or None,
|
||||||
|
summary=t.get("summary") or parsed.get("summary"),
|
||||||
|
progress=t.get("progress"),
|
||||||
|
status=t.get("status") or parsed.get("status") or "in_progress",
|
||||||
|
risk=risk,
|
||||||
|
next_step=(nexts[0] if idx == 0 and nexts else None),
|
||||||
|
source="ai" if payload.mode == "ai" else "manual",
|
||||||
|
raw_text=payload.raw_text,
|
||||||
|
model=payload.model,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
created.append(u)
|
||||||
|
|
||||||
|
if parsed.get("progress") is not None:
|
||||||
|
project.progress = float(parsed["progress"]) or 0
|
||||||
|
if parsed.get("status"):
|
||||||
|
project.status = parsed["status"]
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
ImportLog(
|
||||||
|
project_id=project.id,
|
||||||
|
period_id=period.id,
|
||||||
|
raw_input=payload.raw_text,
|
||||||
|
parsed_json=json.dumps(parsed, ensure_ascii=False),
|
||||||
|
mode=payload.mode,
|
||||||
|
model=payload.model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"project": project.to_dict(),
|
||||||
|
"created": [u.to_dict() for u in created],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ai/logs")
|
||||||
|
def ai_logs(limit: int = 20, db: Session = Depends(get_db)):
|
||||||
|
rows = db.query(ImportLog).order_by(ImportLog.id.desc()).limit(limit).all()
|
||||||
|
return [r.to_dict() for r in rows]
|
||||||
|
|
@ -0,0 +1,478 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
from typing import Iterable
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import constants
|
||||||
|
from ..constants import parse_period_label
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import Period, Project, Task, Update
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodCreate(BaseModel):
|
||||||
|
label: str | None = None
|
||||||
|
year: int | None = None
|
||||||
|
month: int | None = None
|
||||||
|
week: int | None = None
|
||||||
|
start_date: str | None = None
|
||||||
|
end_date: str | None = None
|
||||||
|
|
||||||
|
# 状态严重程度排序(越靠前越需要关注)
|
||||||
|
_SEVERITY = ["blocked", "at_risk", "paused", "not_started", "in_progress", "done"]
|
||||||
|
|
||||||
|
|
||||||
|
def worst_status(codes: Iterable[str]) -> str:
|
||||||
|
codes = [c for c in codes if c]
|
||||||
|
for c in _SEVERITY:
|
||||||
|
if c in codes:
|
||||||
|
return c
|
||||||
|
return "in_progress"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_period_ids(
|
||||||
|
db: Session,
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
) -> list[int]:
|
||||||
|
"""把 (period_id) 或 (year+month) 解析为具体的周周期 id 列表。"""
|
||||||
|
if period_id:
|
||||||
|
p = db.get(Period, period_id)
|
||||||
|
return [p.id] if p else []
|
||||||
|
if year and month:
|
||||||
|
rows = (
|
||||||
|
db.query(Period.id)
|
||||||
|
.filter(Period.year == year, Period.month == month)
|
||||||
|
.order_by(Period.sort_key)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return [r[0] for r in rows]
|
||||||
|
latest = db.query(Period).order_by(Period.sort_key.desc()).first()
|
||||||
|
return [latest.id] if latest else []
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 周期
|
||||||
|
@router.get("/periods")
|
||||||
|
def list_periods(db: Session = Depends(get_db)):
|
||||||
|
from ..models import CustomUpdate
|
||||||
|
|
||||||
|
weeks = db.query(Period).order_by(Period.sort_key).all()
|
||||||
|
uc = dict(
|
||||||
|
db.query(Update.period_id, func.count(Update.id)).group_by(Update.period_id).all()
|
||||||
|
)
|
||||||
|
cc = dict(
|
||||||
|
db.query(CustomUpdate.period_id, func.count(CustomUpdate.id))
|
||||||
|
.group_by(CustomUpdate.period_id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
months: dict[tuple[int, int], dict] = {}
|
||||||
|
week_list = []
|
||||||
|
for p in weeks:
|
||||||
|
d = p.to_dict()
|
||||||
|
d["update_count"] = uc.get(p.id, 0)
|
||||||
|
d["custom_update_count"] = cc.get(p.id, 0)
|
||||||
|
week_list.append(d)
|
||||||
|
|
||||||
|
key = (p.year, p.month)
|
||||||
|
item = months.setdefault(
|
||||||
|
key,
|
||||||
|
{
|
||||||
|
"year": p.year,
|
||||||
|
"month": p.month,
|
||||||
|
"label": f"{p.year}年{p.month}月",
|
||||||
|
"sort_key": p.year * 10000 + p.month * 100,
|
||||||
|
"period_ids": [],
|
||||||
|
"week_count": 0,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
item["period_ids"].append(p.id)
|
||||||
|
item["week_count"] += 1
|
||||||
|
y, m, w = constants.week_of_date(date.today())
|
||||||
|
today_key = y * 10000 + m * 100 + w
|
||||||
|
return {
|
||||||
|
"weeks": week_list,
|
||||||
|
"months": sorted(months.values(), key=lambda x: x["sort_key"]),
|
||||||
|
# 周期表里可能已经预建了后面的周,今天所在的这一周单独给出去当默认值
|
||||||
|
"today_id": next((d["id"] for d in week_list if d["sort_key"] == today_key), None),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/periods")
|
||||||
|
def create_period(payload: PeriodCreate, db: Session = Depends(get_db)):
|
||||||
|
"""新增一个周期。可直接给 label(如『9月第1周』),年份自动接续在最新周期之后。"""
|
||||||
|
latest = db.query(Period).order_by(Period.sort_key.desc()).first()
|
||||||
|
|
||||||
|
year, month, week = payload.year, payload.month, payload.week
|
||||||
|
if month is None:
|
||||||
|
if not payload.label:
|
||||||
|
raise HTTPException(400, "请提供 label(如『9月第1周』)或 month")
|
||||||
|
parsed = parse_period_label(payload.label, 0)
|
||||||
|
if not parsed:
|
||||||
|
raise HTTPException(400, "无法识别周期,请使用『9月第1周』这类格式")
|
||||||
|
label_year, month, week = parsed
|
||||||
|
if label_year: # 标签里显式写了年份
|
||||||
|
year = label_year
|
||||||
|
|
||||||
|
week = week or 0
|
||||||
|
if year is None:
|
||||||
|
if latest is None:
|
||||||
|
year = 2026
|
||||||
|
elif month < latest.month:
|
||||||
|
# 只按月份判断跨年:补录同年更早的周次(如已有9月第4周,再补9月第1周)应留在同一年
|
||||||
|
year = latest.year + 1
|
||||||
|
else:
|
||||||
|
year = latest.year
|
||||||
|
|
||||||
|
sort_key = year * 10000 + month * 100 + week
|
||||||
|
existed = db.query(Period).filter(Period.sort_key == sort_key).one_or_none()
|
||||||
|
if existed:
|
||||||
|
return {"period": existed.to_dict(), "created": False}
|
||||||
|
|
||||||
|
label = (payload.label or "").strip() or f"{month}月第{week}周"
|
||||||
|
p = Period(
|
||||||
|
label=label,
|
||||||
|
kind="week",
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
week=week,
|
||||||
|
start_date=payload.start_date,
|
||||||
|
end_date=payload.end_date,
|
||||||
|
sort_key=sort_key,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return {"period": p.to_dict(), "created": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/periods/{pid}")
|
||||||
|
def delete_period(pid: int, db: Session = Depends(get_db)):
|
||||||
|
"""删除周期。仅允许删除没有任何执行记录(通用 / 定制)的空周期。"""
|
||||||
|
from ..models import CustomUpdate
|
||||||
|
|
||||||
|
p = db.get(Period, pid)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "周期不存在")
|
||||||
|
n1 = db.query(Update).filter(Update.period_id == pid).count()
|
||||||
|
n2 = db.query(CustomUpdate).filter(CustomUpdate.period_id == pid).count()
|
||||||
|
if n1 or n2:
|
||||||
|
raise HTTPException(400, f"该周期下已有 {n1 + n2} 条执行记录,请先删除记录再删周期")
|
||||||
|
db.delete(p)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/meta")
|
||||||
|
def meta(db: Session = Depends(get_db)):
|
||||||
|
from ..ai_parser import ai_enabled, get_ai_settings
|
||||||
|
|
||||||
|
s = get_ai_settings(db)
|
||||||
|
return {
|
||||||
|
"statuses": [
|
||||||
|
{"code": c, "name": n, "color": col} for c, n, col in constants.STATUSES
|
||||||
|
],
|
||||||
|
"priorities": [
|
||||||
|
{"code": c, "name": n, "color": col} for c, n, col in constants.PRIORITIES
|
||||||
|
],
|
||||||
|
"ai": {
|
||||||
|
"enabled": ai_enabled(db),
|
||||||
|
"model": s.get("ai_model"),
|
||||||
|
"api_base": s.get("ai_api_base"),
|
||||||
|
"has_key": bool((s.get("ai_api_key") or "").strip()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 看板
|
||||||
|
@router.get("/board")
|
||||||
|
def board(
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
category_id: int | None = None,
|
||||||
|
key_only: bool = False,
|
||||||
|
keyword: str | None = None,
|
||||||
|
idle: str = Query("key", pattern="^(none|key|all)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
period_ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
if not period_ids:
|
||||||
|
return {"period_ids": [], "periods": [], "rows": [], "stats": None}
|
||||||
|
|
||||||
|
periods = (
|
||||||
|
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
q = db.query(Project).filter(Project.archived.is_(False))
|
||||||
|
if category_id:
|
||||||
|
q = q.filter(Project.category_id == category_id)
|
||||||
|
if key_only:
|
||||||
|
q = q.filter(Project.is_key.is_(True))
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(Project.name.contains(keyword))
|
||||||
|
projects = q.order_by(Project.is_key.desc(), Project.sort_order, Project.id).all()
|
||||||
|
|
||||||
|
ups = (
|
||||||
|
db.query(Update)
|
||||||
|
.filter(Update.period_id.in_(period_ids))
|
||||||
|
.order_by(Update.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
by_project: dict[int, list[Update]] = defaultdict(list)
|
||||||
|
for u in ups:
|
||||||
|
by_project[u.project_id].append(u)
|
||||||
|
|
||||||
|
tasks = (
|
||||||
|
db.query(Task)
|
||||||
|
.filter(Task.project_id.in_([p.id for p in projects]))
|
||||||
|
.order_by(Task.sort_order, Task.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
tasks_by_project: dict[int, list[Task]] = defaultdict(list)
|
||||||
|
for t in tasks:
|
||||||
|
tasks_by_project[t.project_id].append(t)
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for p in projects:
|
||||||
|
items = by_project.get(p.id, [])
|
||||||
|
if not items:
|
||||||
|
if idle == "none" or (idle == "key" and not p.is_key):
|
||||||
|
continue
|
||||||
|
|
||||||
|
task_cards = []
|
||||||
|
used_task_ids = set()
|
||||||
|
for t in tasks_by_project.get(p.id, []):
|
||||||
|
t_ups = [u for u in items if u.task_id == t.id]
|
||||||
|
for u in t_ups:
|
||||||
|
used_task_ids.add(u.id)
|
||||||
|
task_cards.append(
|
||||||
|
{
|
||||||
|
"task": t.to_dict(),
|
||||||
|
"updates": [u.to_dict() for u in t_ups],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
other_ups = [u for u in items if u.id not in used_task_ids]
|
||||||
|
|
||||||
|
progresses = [u.progress for u in items if u.progress is not None]
|
||||||
|
if progresses:
|
||||||
|
progress, progress_known = round(max(progresses), 1), True
|
||||||
|
else:
|
||||||
|
progress, progress_known = round(p.progress or 0, 1), bool(items)
|
||||||
|
status = worst_status([u.status for u in items]) if items else p.status
|
||||||
|
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"project": p.to_dict(),
|
||||||
|
"progress": progress,
|
||||||
|
"progress_known": progress_known,
|
||||||
|
"status": status,
|
||||||
|
"update_count": len(items),
|
||||||
|
"summary": next((u.summary for u in items if u.summary), None)
|
||||||
|
or (items[-1].content or "")[:80]
|
||||||
|
if items
|
||||||
|
else None,
|
||||||
|
"has_update": bool(items),
|
||||||
|
"risks": [u.risk for u in items if u.risk],
|
||||||
|
"task_cards": task_cards,
|
||||||
|
"other_updates": [u.to_dict() for u in other_ups],
|
||||||
|
"updates": [u.to_dict() for u in items],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
rows.sort(
|
||||||
|
key=lambda r: (
|
||||||
|
not r["project"]["is_key"],
|
||||||
|
r["status"] != "blocked",
|
||||||
|
r["status"] != "at_risk",
|
||||||
|
not r["has_update"],
|
||||||
|
-(r["progress"] or 0),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period_ids": period_ids,
|
||||||
|
"periods": [p.to_dict() for p in periods],
|
||||||
|
"rows": rows,
|
||||||
|
"stats": _quick_stats(db, period_ids, rows),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _quick_stats(db: Session, period_ids: list[int], rows: list[dict]) -> dict:
|
||||||
|
updates = db.query(Update).filter(Update.period_id.in_(period_ids)).all()
|
||||||
|
by_status: dict[str, int] = defaultdict(int)
|
||||||
|
for u in updates:
|
||||||
|
by_status[u.status] += 1
|
||||||
|
active = [r for r in rows if r["has_update"]]
|
||||||
|
progresses = [r["progress"] for r in active if r["progress"]]
|
||||||
|
return {
|
||||||
|
"project_total": len(rows),
|
||||||
|
"project_active": len(active),
|
||||||
|
"project_idle": len(rows) - len(active),
|
||||||
|
"key_total": sum(1 for r in rows if r["project"]["is_key"]),
|
||||||
|
"key_updated": sum(1 for r in active if r["project"]["is_key"]),
|
||||||
|
"update_count": len(updates),
|
||||||
|
"avg_progress": round(sum(progresses) / len(progresses), 1) if progresses else 0,
|
||||||
|
"status_counts": {k: by_status.get(k, 0) for k, _, _ in constants.STATUSES},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 统计面板
|
||||||
|
@router.get("/stats")
|
||||||
|
def stats(
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
period_ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
periods = (
|
||||||
|
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
|
||||||
|
if period_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
scope_label = (
|
||||||
|
"、".join(p.label for p in periods[:3]) + ("…" if len(periods) > 3 else "")
|
||||||
|
if periods
|
||||||
|
else (f"{year}年{month}月" if year and month else "—")
|
||||||
|
)
|
||||||
|
|
||||||
|
updates = (
|
||||||
|
db.query(Update).filter(Update.period_id.in_(period_ids)).all() if period_ids else []
|
||||||
|
)
|
||||||
|
projects = db.query(Project).filter(Project.archived.is_(False)).all()
|
||||||
|
|
||||||
|
# 全量:每个周期的更新量 / 覆盖项目数 / 平均进度(趋势图)
|
||||||
|
trend_rows = (
|
||||||
|
db.query(
|
||||||
|
Period.id,
|
||||||
|
Period.label,
|
||||||
|
Period.sort_key,
|
||||||
|
Period.year,
|
||||||
|
Period.month,
|
||||||
|
Period.week,
|
||||||
|
func.count(Update.id),
|
||||||
|
)
|
||||||
|
.outerjoin(Update, Update.period_id == Period.id)
|
||||||
|
.group_by(Period.id)
|
||||||
|
.order_by(Period.sort_key)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
trend = [
|
||||||
|
{
|
||||||
|
"period_id": r[0],
|
||||||
|
"label": r[1],
|
||||||
|
"sort_key": r[2],
|
||||||
|
"year": r[3],
|
||||||
|
"month": r[4],
|
||||||
|
"week": r[5],
|
||||||
|
"update_count": r[6],
|
||||||
|
}
|
||||||
|
for r in trend_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
# 分类分布
|
||||||
|
cat_counter: dict[int, int] = defaultdict(int)
|
||||||
|
for p in projects:
|
||||||
|
cat_counter[p.category_id or 0] += 1
|
||||||
|
cat_names = {}
|
||||||
|
for p in projects:
|
||||||
|
if p.category_id:
|
||||||
|
cat_names[p.category_id] = (p.category.name, p.category.color)
|
||||||
|
category_dist = []
|
||||||
|
for cid, cnt in sorted(cat_counter.items(), key=lambda x: -x[1]):
|
||||||
|
name, color = cat_names.get(cid, ("未分类", "#94a3b8"))
|
||||||
|
category_dist.append({"id": cid, "name": name, "color": color, "count": cnt})
|
||||||
|
|
||||||
|
# 状态分布(本周期)
|
||||||
|
status_dist = []
|
||||||
|
for code, name, color in constants.STATUSES:
|
||||||
|
status_dist.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": name,
|
||||||
|
"color": color,
|
||||||
|
"count": sum(1 for u in updates if u.status == code),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 风险清单
|
||||||
|
risks = [
|
||||||
|
u.to_dict(with_project=True)
|
||||||
|
for u in updates
|
||||||
|
if u.status in constants.NEGATIVE_STATUS or (u.risk or "").strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
# 重点工作未更新
|
||||||
|
updated_ids = {u.project_id for u in updates}
|
||||||
|
idle_key = [
|
||||||
|
p.to_dict() for p in projects if p.is_key and p.id not in updated_ids
|
||||||
|
]
|
||||||
|
|
||||||
|
# 进度排行
|
||||||
|
prog_rows = []
|
||||||
|
for p in projects:
|
||||||
|
p_ups = [u for u in updates if u.project_id == p.id]
|
||||||
|
prs = [u.progress for u in p_ups if u.progress is not None]
|
||||||
|
prog = max(prs) if prs else p.progress
|
||||||
|
prog_rows.append(
|
||||||
|
{
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"progress": round(prog or 0, 1),
|
||||||
|
"is_key": p.is_key,
|
||||||
|
"status": worst_status([u.status for u in p_ups]) if p_ups else p.status,
|
||||||
|
"color": (p.category.color if p.category else "#2563eb"),
|
||||||
|
"estimated": not prs,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
prog_rows.sort(key=lambda x: (-x["progress"], x["name"]))
|
||||||
|
top_progress = [r for r in prog_rows if r["id"] in updated_ids][:10] or prog_rows[:10]
|
||||||
|
low_progress = [
|
||||||
|
r
|
||||||
|
for r in prog_rows
|
||||||
|
if r["progress"] < 30 and r["status"] not in ("done",) and r["id"] in updated_ids
|
||||||
|
][:8]
|
||||||
|
|
||||||
|
recent = (
|
||||||
|
db.query(Update)
|
||||||
|
.filter(Update.period_id.in_(period_ids))
|
||||||
|
.order_by(Update.id.desc())
|
||||||
|
.limit(10)
|
||||||
|
.all()
|
||||||
|
if period_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
covered_prog = [p["progress"] for p in prog_rows if p["id"] in updated_ids]
|
||||||
|
return {
|
||||||
|
"scope_label": scope_label,
|
||||||
|
"period_ids": period_ids,
|
||||||
|
"kpi": {
|
||||||
|
"project_total": len(projects),
|
||||||
|
"key_total": sum(1 for p in projects if p.is_key),
|
||||||
|
"update_count": len(updates),
|
||||||
|
"covered_projects": len(updated_ids),
|
||||||
|
"risk_count": len(risks),
|
||||||
|
# 平均进度只统计本周期有更新的项目,避免历史一次性事项拉低数值
|
||||||
|
"avg_progress": round(sum(covered_prog) / len(covered_prog), 1)
|
||||||
|
if covered_prog
|
||||||
|
else 0,
|
||||||
|
},
|
||||||
|
"trend": trend[-14:],
|
||||||
|
"category_dist": category_dist,
|
||||||
|
"status_dist": status_dist,
|
||||||
|
"risks": risks,
|
||||||
|
"idle_key": idle_key,
|
||||||
|
"top_progress": top_progress,
|
||||||
|
"low_progress": low_progress,
|
||||||
|
"recent_updates": [u.to_dict(with_project=True) for u in recent],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,610 @@
|
||||||
|
"""定制交付项目 API(独立于通用项目体系)。
|
||||||
|
|
||||||
|
关注:商务阶段流转 与 按进度确认收入。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from collections import defaultdict
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import ai_parser
|
||||||
|
from ..constants import (
|
||||||
|
CUSTOM_PIPELINE,
|
||||||
|
CUSTOM_STAGES,
|
||||||
|
CUSTOM_STAGE_MAP,
|
||||||
|
CUSTOM_STAGE_ORDER,
|
||||||
|
CUSTOM_STEP_AGING_DAYS,
|
||||||
|
custom_revenue_ratio,
|
||||||
|
normalize_region,
|
||||||
|
)
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import CustomProject, CustomUpdate, ImportLog, Period, set_people
|
||||||
|
from .board import resolve_period_ids
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
class CustomProjectIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
customer: str | None = None
|
||||||
|
region: str | None = None
|
||||||
|
amount: float | None = None
|
||||||
|
stage: str = "signed"
|
||||||
|
progress: float = 0
|
||||||
|
manual_ratio: float | None = None
|
||||||
|
revenue_amount: float | None = None
|
||||||
|
owner: str | None = None # 交付负责人(定制交付为单人,多负责人只用于重点项目)
|
||||||
|
members: list[str] | None = None # 团队成员
|
||||||
|
entry_date: str | None = None
|
||||||
|
sign_date: str | None = None
|
||||||
|
deliver_date: str | None = None
|
||||||
|
accept_date: str | None = None
|
||||||
|
revenue_date: str | None = None
|
||||||
|
invoice_amount: float | None = None
|
||||||
|
received_amount: float | None = None
|
||||||
|
remark: str | None = None
|
||||||
|
is_key: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CustomParseIn(BaseModel):
|
||||||
|
custom_project_id: int | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
period_id: int | None = None
|
||||||
|
raw_text: str
|
||||||
|
use_ai: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class CustomCommitIn(BaseModel):
|
||||||
|
custom_project_id: int | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
period_id: int
|
||||||
|
raw_text: str
|
||||||
|
parsed: dict
|
||||||
|
mode: str = "ai"
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _ratio(cp: CustomProject) -> float:
|
||||||
|
return custom_revenue_ratio(cp.stage, cp.progress, cp.manual_ratio)
|
||||||
|
|
||||||
|
|
||||||
|
# 环节日期字段 → 该环节对应的阶段
|
||||||
|
_STEP_DATE_STAGE = {field: stage for _k, _n, field, stage, _c in CUSTOM_PIPELINE}
|
||||||
|
|
||||||
|
|
||||||
|
def _advance_stage(cp: CustomProject, payload: dict):
|
||||||
|
"""补录了某个环节的日期,就把商务阶段推进到那一环(只进不退,暂停/关闭不动)。"""
|
||||||
|
if cp.stage in ("paused", "closed"):
|
||||||
|
return
|
||||||
|
target = cp.stage
|
||||||
|
for field, stage in _STEP_DATE_STAGE.items():
|
||||||
|
if field in payload and str(payload.get(field) or "").strip():
|
||||||
|
if CUSTOM_STAGE_ORDER.get(stage, 0) > CUSTOM_STAGE_ORDER.get(target, 0):
|
||||||
|
target = stage
|
||||||
|
cp.stage = target
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_key(cp: CustomProject) -> str:
|
||||||
|
"""入库时间(排序用)。未标注的用创建日期兜底。"""
|
||||||
|
if cp.entry_date:
|
||||||
|
return cp.entry_date
|
||||||
|
return cp.created_at.strftime("%Y-%m-%d") if cp.created_at else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_by_entry(projects):
|
||||||
|
"""按入库时间倒序(新的在前),两者都取不到的排在最后。"""
|
||||||
|
return sorted(projects, key=lambda c: (_entry_key(c), c.id), reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
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}」,请换个名称")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 元信息
|
||||||
|
@router.get("/custom/meta")
|
||||||
|
def custom_meta(db: Session = Depends(get_db)):
|
||||||
|
from ..constants import ALL_REGIONS, REGION_GROUPS
|
||||||
|
|
||||||
|
used = [
|
||||||
|
r[0]
|
||||||
|
for r in db.query(CustomProject.region)
|
||||||
|
.filter(CustomProject.region.isnot(None))
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
if r[0]
|
||||||
|
]
|
||||||
|
order = {r: i for i, r in enumerate(ALL_REGIONS)}
|
||||||
|
return {
|
||||||
|
"stages": [
|
||||||
|
{"code": c, "name": n, "color": col, "base_ratio": round(r * 100, 1)}
|
||||||
|
for c, n, col, r in CUSTOM_STAGES
|
||||||
|
],
|
||||||
|
# 数据中实际用到的区域(按标准顺序排列),用于列表筛选
|
||||||
|
"regions": sorted(used, key=lambda x: order.get(x, 999)),
|
||||||
|
# 标准省级行政区全集(分大区),用于编辑时的下拉
|
||||||
|
"region_groups": [{"group": g, "regions": lst} for g, lst in REGION_GROUPS],
|
||||||
|
# 全过程流水线(入库 → 签单 → 交付 → 验收 → 计收)
|
||||||
|
"pipeline": [
|
||||||
|
{"code": k, "name": n, "date_field": f, "stage": st, "color": col}
|
||||||
|
for k, n, f, st, col in CUSTOM_PIPELINE
|
||||||
|
],
|
||||||
|
"aging_days": CUSTOM_STEP_AGING_DAYS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 列表
|
||||||
|
@router.get("/custom/projects")
|
||||||
|
def list_custom_projects(
|
||||||
|
stage: str | None = None,
|
||||||
|
region: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
key_only: bool = False,
|
||||||
|
owner: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(CustomProject)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(CustomProject.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(CustomProject.archived.is_(True))
|
||||||
|
if stage:
|
||||||
|
q = q.filter(CustomProject.stage == stage)
|
||||||
|
if region:
|
||||||
|
q = q.filter(CustomProject.region == region)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(CustomProject.name.contains(keyword))
|
||||||
|
if key_only:
|
||||||
|
q = q.filter(CustomProject.is_key.is_(True))
|
||||||
|
if owner:
|
||||||
|
q = q.filter(CustomProject.owner == owner)
|
||||||
|
|
||||||
|
return [c.to_dict() for c in _sort_by_entry(q.all())]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/custom/projects")
|
||||||
|
def create_custom_project(payload: CustomProjectIn, db: Session = Depends(get_db)):
|
||||||
|
data = payload.model_dump(exclude={"members"})
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "项目名称不能为空")
|
||||||
|
data["name"] = name
|
||||||
|
data["region"] = normalize_region(data.get("region"))
|
||||||
|
if not data.get("entry_date"):
|
||||||
|
data["entry_date"] = date.today().isoformat()
|
||||||
|
_unique_name(db, name)
|
||||||
|
cp = CustomProject(**data, source="manual")
|
||||||
|
set_people(cp, None, payload.members)
|
||||||
|
db.add(cp)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cp)
|
||||||
|
return cp.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/custom/projects/{cid}")
|
||||||
|
def get_custom_project(cid: int, db: Session = Depends(get_db)):
|
||||||
|
cp = db.get(CustomProject, cid)
|
||||||
|
if not cp:
|
||||||
|
raise HTTPException(404, "定制项目不存在")
|
||||||
|
ups = (
|
||||||
|
db.query(CustomUpdate, Period)
|
||||||
|
.join(Period, Period.id == CustomUpdate.period_id)
|
||||||
|
.filter(CustomUpdate.custom_project_id == cid)
|
||||||
|
.order_by(Period.sort_key)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"project": cp.to_dict(),
|
||||||
|
"updates": [u.to_dict() for u, _ in ups][::-1],
|
||||||
|
"timeline": [
|
||||||
|
{
|
||||||
|
"period": p.to_dict(),
|
||||||
|
"stage": u.stage,
|
||||||
|
"progress": u.progress,
|
||||||
|
"content": u.content,
|
||||||
|
"revenue_delta": u.revenue_delta,
|
||||||
|
"amount_delta": u.amount_delta,
|
||||||
|
}
|
||||||
|
for u, p in ups
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/custom/projects/{cid}")
|
||||||
|
def patch_custom_project(cid: int, payload: dict, db: Session = Depends(get_db)):
|
||||||
|
cp = db.get(CustomProject, cid)
|
||||||
|
if not cp:
|
||||||
|
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",
|
||||||
|
)
|
||||||
|
payload = payload or {}
|
||||||
|
if "name" in payload:
|
||||||
|
new_name = str(payload.get("name") or "").strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(400, "项目名称不能为空")
|
||||||
|
if new_name != cp.name:
|
||||||
|
_unique_name(db, new_name, exclude_id=cp.id)
|
||||||
|
payload["name"] = new_name
|
||||||
|
if "region" in payload:
|
||||||
|
payload["region"] = normalize_region(payload.get("region"))
|
||||||
|
for k, v in payload.items():
|
||||||
|
if k in allowed:
|
||||||
|
setattr(cp, k, v)
|
||||||
|
# 团队成员:名单型字段,整体替换(不传即不改);负责人只有单人,走上面的 owner
|
||||||
|
set_people(cp, None, payload.get("members"))
|
||||||
|
_advance_stage(cp, payload)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(cp)
|
||||||
|
return cp.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/custom/projects/{cid}")
|
||||||
|
def delete_custom_project(cid: int, db: Session = Depends(get_db)):
|
||||||
|
cp = db.get(CustomProject, cid)
|
||||||
|
if not cp:
|
||||||
|
raise HTTPException(404, "定制项目不存在")
|
||||||
|
name = cp.name
|
||||||
|
n_upd = len(cp.updates)
|
||||||
|
db.delete(cp)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "name": name, "deleted_updates": n_upd}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 阶段看板
|
||||||
|
@router.get("/custom/board")
|
||||||
|
def custom_board(
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
region: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
period_ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
periods = (
|
||||||
|
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
|
||||||
|
if period_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
|
||||||
|
q = db.query(CustomProject).filter(CustomProject.archived.is_(False))
|
||||||
|
if region:
|
||||||
|
q = q.filter(CustomProject.region == region)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(CustomProject.name.contains(keyword))
|
||||||
|
projects = _sort_by_entry(q.all())
|
||||||
|
|
||||||
|
# 本期有记录的项目
|
||||||
|
ups = (
|
||||||
|
db.query(CustomUpdate)
|
||||||
|
.filter(CustomUpdate.period_id.in_(period_ids))
|
||||||
|
.all()
|
||||||
|
if period_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
by_project: dict[int, list[CustomUpdate]] = defaultdict(list)
|
||||||
|
for u in ups:
|
||||||
|
by_project[u.custom_project_id].append(u)
|
||||||
|
updated_ids = set(by_project.keys())
|
||||||
|
|
||||||
|
columns = []
|
||||||
|
for code, name, color, _base in CUSTOM_STAGES:
|
||||||
|
bucket = _sort_by_entry([c for c in projects if c.stage == code])
|
||||||
|
columns.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"name": name,
|
||||||
|
"color": color,
|
||||||
|
"count": len(bucket),
|
||||||
|
"amount": round(sum(c.amount or 0 for c in bucket), 2),
|
||||||
|
"projects": [c.to_dict() for c in bucket],
|
||||||
|
"updated_count": sum(1 for c in bucket if c.id in updated_ids),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"period_ids": period_ids,
|
||||||
|
"periods": [p.to_dict() for p in periods],
|
||||||
|
"columns": columns,
|
||||||
|
"updated_ids": sorted(updated_ids),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 收入统计
|
||||||
|
@router.get("/custom/stats")
|
||||||
|
def custom_stats(
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
region: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
period_ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
periods = (
|
||||||
|
db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
|
||||||
|
if period_ids
|
||||||
|
else []
|
||||||
|
)
|
||||||
|
scope_label = (
|
||||||
|
"、".join(p.label for p in periods[:3]) + ("…" if len(periods) > 3 else "")
|
||||||
|
if periods
|
||||||
|
else (f"{year}年{month}月" if year and month else "—")
|
||||||
|
)
|
||||||
|
|
||||||
|
pq = db.query(CustomProject)
|
||||||
|
if archived == "false":
|
||||||
|
pq = pq.filter(CustomProject.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
pq = pq.filter(CustomProject.archived.is_(True))
|
||||||
|
if region:
|
||||||
|
pq = pq.filter(CustomProject.region == region)
|
||||||
|
if keyword:
|
||||||
|
pq = pq.filter(CustomProject.name.contains(keyword))
|
||||||
|
projects = pq.all()
|
||||||
|
|
||||||
|
amount_total = sum(c.amount or 0 for c in projects)
|
||||||
|
revenue_total = sum(c.revenue_amount or 0 for c in projects)
|
||||||
|
confirmed_total = sum((c.amount or 0) * _ratio(c) for c in projects)
|
||||||
|
invoice_total = sum(c.invoice_amount or 0 for c in projects)
|
||||||
|
received_total = sum(c.received_amount or 0 for c in projects)
|
||||||
|
|
||||||
|
# 按阶段
|
||||||
|
stage_agg: dict[str, dict] = {
|
||||||
|
c: {"code": c, "name": n, "color": col, "count": 0, "amount": 0.0, "confirmed": 0.0}
|
||||||
|
for c, n, col, _ in CUSTOM_STAGES
|
||||||
|
}
|
||||||
|
for c in projects:
|
||||||
|
agg = stage_agg.setdefault(
|
||||||
|
c.stage,
|
||||||
|
{"code": c.stage, "name": c.stage, "color": "#94a3b8", "count": 0, "amount": 0.0, "confirmed": 0.0},
|
||||||
|
)
|
||||||
|
agg["count"] += 1
|
||||||
|
agg["amount"] += c.amount or 0
|
||||||
|
agg["confirmed"] += (c.amount or 0) * _ratio(c)
|
||||||
|
|
||||||
|
# 按区域
|
||||||
|
region_agg: dict[str, dict] = defaultdict(lambda: {"count": 0, "amount": 0.0, "confirmed": 0.0, "revenue": 0.0})
|
||||||
|
for c in projects:
|
||||||
|
r = region_agg[c.region or "未标注"]
|
||||||
|
r["count"] += 1
|
||||||
|
r["amount"] += c.amount or 0
|
||||||
|
r["confirmed"] += (c.amount or 0) * _ratio(c)
|
||||||
|
r["revenue"] += c.revenue_amount or 0
|
||||||
|
|
||||||
|
by_region = [
|
||||||
|
{"name": k, **{kk: round(vv, 2) for kk, vv in v.items() if kk != "count"}, "count": v["count"]}
|
||||||
|
for k, v in sorted(region_agg.items(), key=lambda x: -x[1]["amount"])
|
||||||
|
]
|
||||||
|
|
||||||
|
# 各周期:更新项目数 / 新增计收
|
||||||
|
trend_rows = (
|
||||||
|
db.query(
|
||||||
|
Period.id, Period.label, Period.sort_key, Period.year, Period.month, Period.week,
|
||||||
|
func.count(CustomUpdate.id),
|
||||||
|
)
|
||||||
|
.outerjoin(CustomUpdate, CustomUpdate.period_id == Period.id)
|
||||||
|
.group_by(Period.id)
|
||||||
|
.order_by(Period.sort_key)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
trend = [
|
||||||
|
{
|
||||||
|
"period_id": r[0], "label": r[1], "sort_key": r[2], "year": r[3],
|
||||||
|
"month": r[4], "week": r[5], "update_count": r[6],
|
||||||
|
}
|
||||||
|
for r in trend_rows
|
||||||
|
]
|
||||||
|
|
||||||
|
ups_in_scope = (
|
||||||
|
db.query(CustomUpdate).filter(CustomUpdate.period_id.in_(period_ids)).all() if period_ids else []
|
||||||
|
)
|
||||||
|
period_revenue = sum(u.revenue_delta or 0 for u in ups_in_scope)
|
||||||
|
period_amount = sum(u.amount_delta or 0 for u in ups_in_scope)
|
||||||
|
|
||||||
|
top = sorted(projects, key=lambda c: -(c.amount or 0))[:12]
|
||||||
|
recently_stage_changed = sorted(
|
||||||
|
[c for c in projects if c.period_id in period_ids],
|
||||||
|
key=lambda c: CUSTOM_STAGE_ORDER.get(c.stage, 99),
|
||||||
|
)[:10]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"scope_label": scope_label,
|
||||||
|
"period_ids": period_ids,
|
||||||
|
"kpi": {
|
||||||
|
"project_count": len(projects),
|
||||||
|
"key_count": sum(1 for c in projects if c.is_key),
|
||||||
|
"amount_total": round(amount_total, 2),
|
||||||
|
"revenue_total": round(revenue_total, 2),
|
||||||
|
"confirmed_total": round(confirmed_total, 2),
|
||||||
|
"pending_total": round(max(0.0, confirmed_total - revenue_total), 2),
|
||||||
|
"progress_rate": round(confirmed_total / amount_total * 100, 1) if amount_total else 0,
|
||||||
|
"revenue_rate": round(revenue_total / amount_total * 100, 1) if amount_total else 0,
|
||||||
|
"invoice_total": round(invoice_total, 2),
|
||||||
|
"received_total": round(received_total, 2),
|
||||||
|
"pending_invoice": round(max(0.0, amount_total - invoice_total), 2),
|
||||||
|
"pending_received": round(max(0.0, amount_total - received_total), 2),
|
||||||
|
"received_rate": round(received_total / amount_total * 100, 1) if amount_total else 0,
|
||||||
|
"period_revenue": round(period_revenue, 2),
|
||||||
|
"period_amount": round(period_amount, 2),
|
||||||
|
"period_updated": len({u.custom_project_id for u in ups_in_scope}),
|
||||||
|
},
|
||||||
|
"by_stage": [stage_agg[c] for c, _, _, _ in CUSTOM_STAGES if stage_agg[c]["count"]],
|
||||||
|
"by_region": by_region,
|
||||||
|
"trend": trend[-14:],
|
||||||
|
"top_projects": [c.to_dict() for c in top],
|
||||||
|
"recent_changes": [c.to_dict() for c in recently_stage_changed],
|
||||||
|
"recent_updates": [u.to_dict() for u in sorted(ups_in_scope, key=lambda x: -x.id)[:10]],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 执行记录
|
||||||
|
@router.post("/custom/updates")
|
||||||
|
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, "定制项目不存在")
|
||||||
|
u = CustomUpdate(
|
||||||
|
custom_project_id=payload["custom_project_id"],
|
||||||
|
period_id=payload["period_id"],
|
||||||
|
stage=payload.get("stage") or cp.stage,
|
||||||
|
content=payload.get("content"),
|
||||||
|
summary=payload.get("summary"),
|
||||||
|
progress=payload.get("progress"),
|
||||||
|
amount_delta=payload.get("amount_delta"),
|
||||||
|
revenue_delta=payload.get("revenue_delta"),
|
||||||
|
risk=payload.get("risk"),
|
||||||
|
next_step=payload.get("next_step"),
|
||||||
|
owner=payload.get("owner"),
|
||||||
|
source=payload.get("source", "manual"),
|
||||||
|
raw_text=payload.get("raw_text"),
|
||||||
|
model=payload.get("model"),
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
if payload.get("progress") is not None:
|
||||||
|
cp.progress = float(payload["progress"])
|
||||||
|
if payload.get("stage"):
|
||||||
|
cp.stage = payload["stage"]
|
||||||
|
if payload.get("revenue_delta"):
|
||||||
|
cp.revenue_amount = (cp.revenue_amount or 0) + float(payload["revenue_delta"])
|
||||||
|
cp.period_id = payload["period_id"]
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
return u.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/custom/updates/{uid}")
|
||||||
|
def delete_custom_update(uid: int, db: Session = Depends(get_db)):
|
||||||
|
u = db.get(CustomUpdate, uid)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(404, "记录不存在")
|
||||||
|
db.delete(u)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- AI 录入
|
||||||
|
@router.post("/custom/ai/parse")
|
||||||
|
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, "定制项目不存在")
|
||||||
|
period = db.get(Period, payload.period_id) if payload.period_id else None
|
||||||
|
|
||||||
|
parsed, mode, error = ai_parser.parse_custom(
|
||||||
|
payload.raw_text,
|
||||||
|
cp.name if cp else (payload.project_name or ""),
|
||||||
|
period.label if period else "",
|
||||||
|
cp.stage if cp else "",
|
||||||
|
db,
|
||||||
|
use_ai=payload.use_ai,
|
||||||
|
)
|
||||||
|
warning = parsed.pop("_warning", None)
|
||||||
|
return {
|
||||||
|
"mode": mode,
|
||||||
|
"error": error,
|
||||||
|
"warning": warning,
|
||||||
|
"parsed": parsed,
|
||||||
|
"project": cp.to_dict() if cp else None,
|
||||||
|
"period": period.to_dict() if period else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/custom/ai/commit")
|
||||||
|
def custom_ai_commit(payload: CustomCommitIn, db: Session = Depends(get_db)):
|
||||||
|
cp = db.get(CustomProject, payload.custom_project_id) if payload.custom_project_id else None
|
||||||
|
if not cp:
|
||||||
|
name = (payload.project_name or (payload.parsed or {}).get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "缺少项目信息")
|
||||||
|
cp = db.query(CustomProject).filter(CustomProject.name == name).one_or_none()
|
||||||
|
if not cp:
|
||||||
|
cp = CustomProject(name=name, stage="signed", source="ai")
|
||||||
|
db.add(cp)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
period = db.get(Period, payload.period_id)
|
||||||
|
if not period:
|
||||||
|
raise HTTPException(404, "周期不存在")
|
||||||
|
|
||||||
|
p = payload.parsed or {}
|
||||||
|
created = []
|
||||||
|
items = p.get("updates") or []
|
||||||
|
if not items:
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"content": payload.raw_text,
|
||||||
|
"progress": p.get("progress"),
|
||||||
|
"stage": p.get("stage") or cp.stage,
|
||||||
|
"revenue_delta": p.get("revenue_delta"),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
risks = p.get("risks") or []
|
||||||
|
nexts = p.get("next_steps") or []
|
||||||
|
for idx, it in enumerate(items):
|
||||||
|
u = CustomUpdate(
|
||||||
|
custom_project_id=cp.id,
|
||||||
|
period_id=period.id,
|
||||||
|
stage=it.get("stage") or p.get("stage") or cp.stage,
|
||||||
|
content=it.get("content") or "",
|
||||||
|
summary=p.get("summary") if idx == 0 else None,
|
||||||
|
progress=it.get("progress") if it.get("progress") is not None else p.get("progress"),
|
||||||
|
revenue_delta=it.get("revenue_delta"),
|
||||||
|
amount_delta=it.get("amount_delta"),
|
||||||
|
risk=(risks[0] if idx == 0 and risks else None),
|
||||||
|
next_step=(nexts[0] if idx == 0 and nexts else None),
|
||||||
|
source="ai" if payload.mode == "ai" else "manual",
|
||||||
|
raw_text=payload.raw_text,
|
||||||
|
model=payload.model,
|
||||||
|
)
|
||||||
|
db.add(u)
|
||||||
|
created.append(u)
|
||||||
|
|
||||||
|
# 回写项目主档
|
||||||
|
if p.get("stage"):
|
||||||
|
cp.stage = p["stage"]
|
||||||
|
if p.get("progress") is not None:
|
||||||
|
cp.progress = float(p["progress"])
|
||||||
|
if p.get("amount") is not None:
|
||||||
|
cp.amount = float(p["amount"])
|
||||||
|
if p.get("revenue_amount") is not None:
|
||||||
|
cp.revenue_amount = float(p["revenue_amount"])
|
||||||
|
elif p.get("revenue_delta"):
|
||||||
|
cp.revenue_amount = (cp.revenue_amount or 0) + float(p["revenue_delta"])
|
||||||
|
if p.get("summary"):
|
||||||
|
cp.remark = p["summary"]
|
||||||
|
cp.period_id = period.id
|
||||||
|
|
||||||
|
db.add(
|
||||||
|
ImportLog(
|
||||||
|
project_id=None,
|
||||||
|
period_id=period.id,
|
||||||
|
raw_input=payload.raw_text,
|
||||||
|
parsed_json=json.dumps({"custom_project_id": cp.id, **p}, ensure_ascii=False),
|
||||||
|
mode=payload.mode,
|
||||||
|
model=payload.model,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"project": cp.to_dict(),
|
||||||
|
"created": [u.to_dict() for u in created],
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,392 @@
|
||||||
|
"""数据导出(Excel / xlsx)。
|
||||||
|
|
||||||
|
提供四类导出,均可带上页面当前的筛选条件:
|
||||||
|
- /export/custom 定制交付项目明细
|
||||||
|
- /export/custom-updates 定制项目周期记录
|
||||||
|
- /export/projects 常规项目管理
|
||||||
|
- /export/updates 常规项目周期执行记录
|
||||||
|
- /export/staff 人力资源库(含项目负载)
|
||||||
|
- /export/focus 每周重点工作
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..constants import CUSTOM_STAGE_MAP, STATUS_MAP
|
||||||
|
from ..db import get_db
|
||||||
|
from ..export import build_xlsx, xlsx_response
|
||||||
|
from ..models import (
|
||||||
|
CustomProject,
|
||||||
|
CustomUpdate,
|
||||||
|
Period,
|
||||||
|
Project,
|
||||||
|
Staff,
|
||||||
|
Task,
|
||||||
|
Update,
|
||||||
|
WeeklyFocus,
|
||||||
|
)
|
||||||
|
from .board import worst_status
|
||||||
|
from .custom import _sort_by_entry
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_name(code: str | None) -> str:
|
||||||
|
return CUSTOM_STAGE_MAP.get(code or "", (code or "", "", 0))[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _resp(content: bytes, filename: str) -> Response:
|
||||||
|
body, headers = xlsx_response(content, filename)
|
||||||
|
return Response(content=body, headers=headers)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 定制交付
|
||||||
|
@router.get("/export/custom")
|
||||||
|
def export_custom(
|
||||||
|
region: str | None = None,
|
||||||
|
stage: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(CustomProject)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(CustomProject.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(CustomProject.archived.is_(True))
|
||||||
|
if region:
|
||||||
|
q = q.filter(CustomProject.region == region)
|
||||||
|
if stage:
|
||||||
|
q = q.filter(CustomProject.stage == stage)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(CustomProject.name.contains(keyword))
|
||||||
|
projects = _sort_by_entry(q.all())
|
||||||
|
|
||||||
|
periods = {p.id: p for p in db.query(Period).all()}
|
||||||
|
headers = [
|
||||||
|
"项目名称", "区域", "客户", "商务阶段", "负责人",
|
||||||
|
"合同额(元)", "入库时间", "签单时间", "交付时间", "验收时间", "计收时间",
|
||||||
|
"开发进度(%)", "收入确认比例(%)", "可确认收入(元)",
|
||||||
|
"已开票(元)", "待开票(元)", "已回款(元)", "待回款(元)",
|
||||||
|
"已计收(元)", "待计收(元)", "最近更新周期", "重点", "归档", "备注",
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for c in projects:
|
||||||
|
d = c.to_dict()
|
||||||
|
p = periods.get(c.period_id)
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
c.name,
|
||||||
|
c.region or "",
|
||||||
|
c.customer or "",
|
||||||
|
_stage_name(c.stage),
|
||||||
|
c.owner or "",
|
||||||
|
c.amount,
|
||||||
|
c.entry_date or "",
|
||||||
|
c.sign_date or "",
|
||||||
|
c.deliver_date 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],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
dicts = [c.to_dict() for c in projects]
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
return _resp(content, "定制交付项目明细")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/custom-updates")
|
||||||
|
def export_custom_updates(
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
from .board import resolve_period_ids
|
||||||
|
|
||||||
|
period_ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
q = db.query(CustomUpdate)
|
||||||
|
if period_ids:
|
||||||
|
q = q.filter(CustomUpdate.period_id.in_(period_ids))
|
||||||
|
ups = q.order_by(CustomUpdate.period_id, CustomUpdate.custom_project_id).all()
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
"周期", "项目名称", "区域", "阶段", "执行情况", "进度(%)",
|
||||||
|
"本期新增下单(元)", "本期新增计收(元)", "风险", "下一步",
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for u in ups:
|
||||||
|
cp = u.project
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
u.period.to_dict()["full_label"] if u.period else "",
|
||||||
|
cp.name if cp else "",
|
||||||
|
(cp.region or "") if cp else "",
|
||||||
|
_stage_name(u.stage),
|
||||||
|
(u.content or "").replace("\n", " ")[:500],
|
||||||
|
u.progress,
|
||||||
|
u.amount_delta,
|
||||||
|
u.revenue_delta,
|
||||||
|
(u.risk or "").replace("\n", " ")[:200],
|
||||||
|
(u.next_step or "").replace("\n", " ")[:200],
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
content = build_xlsx(
|
||||||
|
"定制项目记录",
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
widths=[18, 42, 10, 14, 70, 10, 16, 16, 30, 30],
|
||||||
|
title="定制项目周期记录",
|
||||||
|
money_cols=(7, 8),
|
||||||
|
)
|
||||||
|
return _resp(content, "定制项目周期记录")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 常规项目
|
||||||
|
@router.get("/export/projects")
|
||||||
|
def export_projects(
|
||||||
|
category_id: int | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
archived: str = Query("all", pattern="^(true|false|all)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(Project)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(Project.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(Project.archived.is_(True))
|
||||||
|
if category_id:
|
||||||
|
q = q.filter(Project.category_id == category_id)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(Project.name.contains(keyword))
|
||||||
|
projects = q.order_by(Project.is_key.desc(), Project.name).all()
|
||||||
|
|
||||||
|
periods = {p.id: p for p in db.query(Period).all()}
|
||||||
|
headers = [
|
||||||
|
"项目名称", "分类", "负责人", "状态", "优先级", "重点",
|
||||||
|
"进度(%)", "标签", "子任务数", "执行记录数", "最近更新周期", "归档",
|
||||||
|
]
|
||||||
|
status_names = {
|
||||||
|
"not_started": "未开始", "in_progress": "进行中", "done": "已完成",
|
||||||
|
"at_risk": "风险", "blocked": "阻塞", "paused": "暂停/终止",
|
||||||
|
}
|
||||||
|
rows = []
|
||||||
|
for p in projects:
|
||||||
|
ups = sorted(p.updates, key=lambda u: u.period_id or 0, reverse=True)
|
||||||
|
last = periods.get(ups[0].period_id) if ups else None
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
p.name,
|
||||||
|
p.category.name if p.category else "未分类",
|
||||||
|
p.owner or "",
|
||||||
|
status_names.get(p.status, p.status),
|
||||||
|
(p.priority or "").upper(),
|
||||||
|
"是" if p.is_key else "",
|
||||||
|
round(p.progress or 0, 1),
|
||||||
|
"、".join(t.name for t in p.tags),
|
||||||
|
len(p.tasks),
|
||||||
|
len(p.updates),
|
||||||
|
last.to_dict()["full_label"] if last else "",
|
||||||
|
"是" if p.archived else "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
content = build_xlsx(
|
||||||
|
"项目管理",
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
widths=[46, 12, 12, 10, 9, 7, 10, 24, 11, 12, 18, 7],
|
||||||
|
title="项目管理清单",
|
||||||
|
)
|
||||||
|
return _resp(content, "项目管理清单")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/export/updates")
|
||||||
|
def export_updates(
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
project_id: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
from .board import resolve_period_ids
|
||||||
|
|
||||||
|
period_ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
q = db.query(Update)
|
||||||
|
if period_ids:
|
||||||
|
q = q.filter(Update.period_id.in_(period_ids))
|
||||||
|
if project_id:
|
||||||
|
q = q.filter(Update.project_id == project_id)
|
||||||
|
ups = q.order_by(Update.period_id, Update.project_id, Update.id).all()
|
||||||
|
|
||||||
|
status_names = {
|
||||||
|
"not_started": "未开始", "in_progress": "进行中", "done": "已完成",
|
||||||
|
"at_risk": "风险", "blocked": "阻塞", "paused": "暂停/终止",
|
||||||
|
}
|
||||||
|
headers = [
|
||||||
|
"周期", "项目", "子任务", "执行情况", "进度(%)", "状态", "风险", "下一步", "来源",
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for u in ups:
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
u.period.to_dict()["full_label"] if u.period else "",
|
||||||
|
u.project.name if u.project else "",
|
||||||
|
u.task.name if u.task else "",
|
||||||
|
(u.content or "").replace("\n", " ")[:500],
|
||||||
|
u.progress,
|
||||||
|
status_names.get(u.status, u.status),
|
||||||
|
(u.risk or "").replace("\n", " ")[:200],
|
||||||
|
(u.next_step or "").replace("\n", " ")[:200],
|
||||||
|
{"import": "导入", "ai": "AI整理", "manual": "手工"}.get(u.source, u.source),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
content = build_xlsx(
|
||||||
|
"周期执行记录",
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
widths=[18, 30, 22, 70, 10, 10, 30, 30, 10],
|
||||||
|
title="周期执行记录",
|
||||||
|
)
|
||||||
|
return _resp(content, "周期执行记录")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 人力资源库
|
||||||
|
@router.get("/export/staff")
|
||||||
|
def export_staff(
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
from .staff import _workload
|
||||||
|
|
||||||
|
q = db.query(Staff)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(Staff.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(Staff.archived.is_(True))
|
||||||
|
members = q.order_by(Staff.sort_order, Staff.id).all()
|
||||||
|
load = _workload(db)
|
||||||
|
|
||||||
|
headers = [
|
||||||
|
"序号", "姓名", "归属小组", "人员归属", "在管项目", "重点项目",
|
||||||
|
"定制项目", "定制合同额(元)", "定制待计收(元)", "最近更新", "说明", "归档",
|
||||||
|
]
|
||||||
|
rows = []
|
||||||
|
for i, m in enumerate(members, 1):
|
||||||
|
w = load.get(m.name, {})
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
i,
|
||||||
|
m.name,
|
||||||
|
m.group or "",
|
||||||
|
m.affiliation or "",
|
||||||
|
w.get("project_count", 0),
|
||||||
|
w.get("key_project_count", 0),
|
||||||
|
w.get("custom_count", 0),
|
||||||
|
w.get("custom_amount", 0),
|
||||||
|
w.get("custom_pending", 0),
|
||||||
|
w.get("last_update") or "",
|
||||||
|
(m.note or "").replace("\n", " "),
|
||||||
|
"是" if m.archived else "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
content = build_xlsx(
|
||||||
|
"人力资源库",
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
widths=[6, 12, 14, 10, 10, 10, 10, 16, 16, 16, 52, 7],
|
||||||
|
title="软件开发部人力资源库",
|
||||||
|
money_cols=(8, 9),
|
||||||
|
)
|
||||||
|
return _resp(content, "人力资源库")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 每周重点工作
|
||||||
|
@router.get("/export/focus")
|
||||||
|
def export_focus(
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
status: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(WeeklyFocus)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(WeeklyFocus.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(WeeklyFocus.archived.is_(True))
|
||||||
|
if status:
|
||||||
|
q = q.filter(WeeklyFocus.status == status)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(WeeklyFocus.content.contains(keyword) | WeeklyFocus.detail.contains(keyword))
|
||||||
|
periods = {p.id: p for p in db.query(Period).all()}
|
||||||
|
items = sorted(
|
||||||
|
q.all(),
|
||||||
|
key=lambda f: (-(periods[f.period_id].sort_key if f.period_id in periods else 0), -f.id),
|
||||||
|
)
|
||||||
|
|
||||||
|
headers = ["序号", "工作内容", "说明", "预计完成时间", "录入周", "执行状态", "是否延期", "结果反馈", "更新"]
|
||||||
|
rows = []
|
||||||
|
for i, f in enumerate(items, 1):
|
||||||
|
period = periods.get(f.period_id)
|
||||||
|
rows.append(
|
||||||
|
[
|
||||||
|
i,
|
||||||
|
f.content,
|
||||||
|
(f.detail or "").replace("\n", " "),
|
||||||
|
f.due_date or "",
|
||||||
|
period.to_dict()["full_label"] if period else "",
|
||||||
|
STATUS_MAP.get(f.status, (f.status,))[0],
|
||||||
|
"是" if f.delayed else "",
|
||||||
|
(f.result or "").replace("\n", " "),
|
||||||
|
f.updated_at.strftime("%Y-%m-%d %H:%M") if f.updated_at else "",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
content = build_xlsx(
|
||||||
|
"每周重点工作",
|
||||||
|
headers,
|
||||||
|
rows,
|
||||||
|
widths=[6, 46, 54, 14, 14, 12, 10, 54, 17],
|
||||||
|
title="每周重点工作",
|
||||||
|
)
|
||||||
|
return _resp(content, "每周重点工作")
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
"""每周重点工作:单条录入、按周跟踪的短期任务。
|
||||||
|
|
||||||
|
和项目管理(Project + Update)分开:不用先建项目档案,一条就是一个活儿。
|
||||||
|
字段按需求——工作内容 / 说明 / 预计完成时间(日历日期)/ 执行状态 / 结果反馈 / 是否延期。
|
||||||
|
period_id 是「录入周」——新建时顶栏选中的那一周,和项目管理/定制交付的周期归属同一个意思;
|
||||||
|
预计完成时间只存 due_date,不再按日期反查周期。本周录一条两周后要干的事,本周照样看得见。
|
||||||
|
「是否延期」是人工开关(delayed):更早周次录入、标了延期又没做完的,结转进当周视图。
|
||||||
|
录入整理在「每周重点工作」页,汇总在「工作看板」。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import constants
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import Period, WeeklyFocus
|
||||||
|
from ..schemas import FocusIn, FocusPatch
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _week_period(db: Session, year: int, month: int, week: int) -> Period:
|
||||||
|
"""取 periods 里的这一周,没有就补一条。"""
|
||||||
|
sort_key = year * 10000 + month * 100 + week
|
||||||
|
target = (
|
||||||
|
db.query(Period)
|
||||||
|
.filter(Period.kind == "week", Period.sort_key == sort_key)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if target is None:
|
||||||
|
target = Period(
|
||||||
|
label=f"{month}月第{week}周",
|
||||||
|
kind="week",
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
week=week,
|
||||||
|
sort_key=sort_key,
|
||||||
|
)
|
||||||
|
db.add(target)
|
||||||
|
db.flush()
|
||||||
|
return target
|
||||||
|
|
||||||
|
|
||||||
|
def _entry_period(db: Session, period_id: int | None) -> Period:
|
||||||
|
"""录入周:前端传顶栏当前选中的那一周;没传(脚本/API 直接调)就按今天所在的周。"""
|
||||||
|
if period_id:
|
||||||
|
existed = db.get(Period, period_id)
|
||||||
|
if existed:
|
||||||
|
return existed
|
||||||
|
raise HTTPException(400, "录入周不存在")
|
||||||
|
return _week_period(db, *constants.week_of_date(date.today()))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_date(value: str | None) -> str | None:
|
||||||
|
"""校验日历日期,存 ISO 字符串。"""
|
||||||
|
raw = (value or "").strip()
|
||||||
|
if not raw:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(raw[:10]).isoformat()
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(400, f"预计完成时间格式不对:{raw}(应为 YYYY-MM-DD)")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/focus")
|
||||||
|
def list_focus(
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
status: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(WeeklyFocus)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(WeeklyFocus.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(WeeklyFocus.archived.is_(True))
|
||||||
|
if status:
|
||||||
|
q = q.filter(WeeklyFocus.status == status)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(WeeklyFocus.content.contains(keyword) | WeeklyFocus.detail.contains(keyword))
|
||||||
|
return [f.to_dict() for f in q.order_by(WeeklyFocus.id.desc()).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/focus")
|
||||||
|
def create_focus(payload: FocusIn, db: Session = Depends(get_db)):
|
||||||
|
content = (payload.content or "").strip()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(400, "请填写工作内容")
|
||||||
|
if payload.status not in constants.STATUS_CODES:
|
||||||
|
raise HTTPException(400, f"未知状态:{payload.status}")
|
||||||
|
due = _clean_date(payload.due_date)
|
||||||
|
item = WeeklyFocus(
|
||||||
|
content=content,
|
||||||
|
detail=(payload.detail or "").strip() or None,
|
||||||
|
period_id=_entry_period(db, payload.period_id).id,
|
||||||
|
due_date=due,
|
||||||
|
result=(payload.result or "").strip() or None,
|
||||||
|
delayed=bool(payload.delayed),
|
||||||
|
status=payload.status,
|
||||||
|
)
|
||||||
|
db.add(item)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/focus/{fid}")
|
||||||
|
def patch_focus(fid: int, payload: FocusPatch, db: Session = Depends(get_db)):
|
||||||
|
item = db.get(WeeklyFocus, fid)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "条目不存在")
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
if "content" in data:
|
||||||
|
content = (data["content"] or "").strip()
|
||||||
|
if not content:
|
||||||
|
raise HTTPException(400, "请填写工作内容")
|
||||||
|
data["content"] = content
|
||||||
|
if "detail" in data:
|
||||||
|
data["detail"] = (data["detail"] or "").strip() or None
|
||||||
|
if "status" in data and data["status"] not in constants.STATUS_CODES:
|
||||||
|
raise HTTPException(400, f"未知状态:{data['status']}")
|
||||||
|
if "result" in data:
|
||||||
|
data["result"] = (data["result"] or "").strip() or None
|
||||||
|
if "due_date" in data:
|
||||||
|
data["due_date"] = _clean_date(data["due_date"])
|
||||||
|
# 改预计完成时间不再把条目搬去别的周,只有显式指定录入周才动 period_id
|
||||||
|
if data.get("period_id"):
|
||||||
|
data["period_id"] = _entry_period(db, data["period_id"]).id
|
||||||
|
else:
|
||||||
|
data.pop("period_id", None)
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(item, key, value)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(item)
|
||||||
|
return item.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/focus/{fid}")
|
||||||
|
def delete_focus(fid: int, db: Session = Depends(get_db)):
|
||||||
|
item = db.get(WeeklyFocus, fid)
|
||||||
|
if not item:
|
||||||
|
raise HTTPException(404, "条目不存在")
|
||||||
|
content = item.content
|
||||||
|
db.delete(item)
|
||||||
|
db.commit()
|
||||||
|
return {"id": fid, "content": content}
|
||||||
|
|
@ -0,0 +1,410 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import (
|
||||||
|
Category,
|
||||||
|
Period,
|
||||||
|
Project,
|
||||||
|
Setting,
|
||||||
|
Tag,
|
||||||
|
Task,
|
||||||
|
Update,
|
||||||
|
people_like,
|
||||||
|
set_people,
|
||||||
|
)
|
||||||
|
from ..schemas import (
|
||||||
|
CategoryIn,
|
||||||
|
ProjectIn,
|
||||||
|
ProjectPatch,
|
||||||
|
TagIn,
|
||||||
|
UpdateIn,
|
||||||
|
)
|
||||||
|
from .board import resolve_period_ids, worst_status
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_tags(db: Session, project: Project, names: list[str] | None):
|
||||||
|
if names is None:
|
||||||
|
return
|
||||||
|
tags = []
|
||||||
|
for n in names:
|
||||||
|
n = (n or "").strip()
|
||||||
|
if not n:
|
||||||
|
continue
|
||||||
|
t = db.query(Tag).filter(Tag.name == n).one_or_none()
|
||||||
|
if not t:
|
||||||
|
t = Tag(name=n)
|
||||||
|
db.add(t)
|
||||||
|
db.flush()
|
||||||
|
tags.append(t)
|
||||||
|
project.tags = tags
|
||||||
|
|
||||||
|
|
||||||
|
def _scope_index(db: Session, period_id: int | None, year: int | None, month: int | None):
|
||||||
|
"""返回 (范围内周期 id, 基准周期 sort_key, {各周 sort_key: 序号}),序号差即「隔了几周没更新」。"""
|
||||||
|
ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
weeks = db.query(Period).order_by(Period.sort_key).all()
|
||||||
|
index = {w.sort_key: i for i, w in enumerate(weeks)}
|
||||||
|
if ids:
|
||||||
|
ref = max(w.sort_key for w in weeks if w.id in set(ids))
|
||||||
|
else:
|
||||||
|
ref = weeks[-1].sort_key if weeks else 0
|
||||||
|
return ids, ref, index
|
||||||
|
|
||||||
|
|
||||||
|
def _idle_weeks(last_sort: int | None, ref_sort: int, index: dict[int, int]) -> int | None:
|
||||||
|
"""距基准周期几周没有执行记录;从没更新过返回 None。"""
|
||||||
|
if not last_sort:
|
||||||
|
return None
|
||||||
|
return max(0, index.get(ref_sort, 0) - index.get(last_sort, index.get(ref_sort, 0)))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 项目
|
||||||
|
@router.get("/projects")
|
||||||
|
def list_projects(
|
||||||
|
category_id: int | None = None,
|
||||||
|
is_key: bool | None = None,
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
keyword: str | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""项目清单。附带「本周期是否更新 / 已连续几周没更新」,周更管理一眼可见。"""
|
||||||
|
ids, ref_sort, week_index = _scope_index(db, period_id, year, month)
|
||||||
|
q = db.query(Project)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(Project.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(Project.archived.is_(True))
|
||||||
|
if category_id:
|
||||||
|
q = q.filter(Project.category_id == category_id)
|
||||||
|
if is_key is not None:
|
||||||
|
q = q.filter(Project.is_key.is_(is_key))
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(Project.name.contains(keyword))
|
||||||
|
if owner:
|
||||||
|
# 负责人筛选连其他负责人一起命中(主负责人在 owner 列,其余在 other_owners 列)
|
||||||
|
q = q.filter(or_(Project.owner == owner, Project.other_owners.like(people_like(owner))))
|
||||||
|
projects = q.order_by(Project.is_key.desc(), Project.sort_order, Project.id).all()
|
||||||
|
|
||||||
|
# 最近一次更新
|
||||||
|
ups = (
|
||||||
|
db.query(Update, Period)
|
||||||
|
.join(Period, Period.id == Update.period_id)
|
||||||
|
.order_by(Period.sort_key.desc(), Update.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
last: dict[int, dict] = {}
|
||||||
|
last_sort: dict[int, int] = {}
|
||||||
|
counts: dict[int, int] = defaultdict(int)
|
||||||
|
for u, p in ups: # 按周期倒序遍历,第一条即该项目最近一次记录
|
||||||
|
counts[u.project_id] += 1
|
||||||
|
if u.project_id not in last:
|
||||||
|
last_sort[u.project_id] = p.sort_key
|
||||||
|
last[u.project_id] = {
|
||||||
|
"period_label": p.label,
|
||||||
|
"period_full": p.to_dict()["full_label"],
|
||||||
|
"status": u.status,
|
||||||
|
"summary": u.summary,
|
||||||
|
"progress": u.progress,
|
||||||
|
}
|
||||||
|
|
||||||
|
# 范围内有执行记录的项目(月粒度时不能只看基准周)
|
||||||
|
in_scope = {
|
||||||
|
row[0]
|
||||||
|
for row in db.query(Update.project_id)
|
||||||
|
.filter(Update.period_id.in_(ids))
|
||||||
|
.distinct()
|
||||||
|
.all()
|
||||||
|
} if ids else set()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for p in projects:
|
||||||
|
d = p.to_dict()
|
||||||
|
d["last_update"] = last.get(p.id)
|
||||||
|
d["update_count"] = counts.get(p.id, 0)
|
||||||
|
d["task_count"] = len(p.tasks)
|
||||||
|
d["idle_weeks"] = _idle_weeks(last_sort.get(p.id), ref_sort, week_index)
|
||||||
|
d["updated_this_period"] = p.id in in_scope
|
||||||
|
result.append(d)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/projects")
|
||||||
|
def create_project(payload: ProjectIn, db: Session = Depends(get_db)):
|
||||||
|
if db.query(Project).filter(Project.name == payload.name).one_or_none():
|
||||||
|
raise HTTPException(400, "同名项目已存在")
|
||||||
|
data = payload.model_dump(exclude={"tags", "owners", "members"})
|
||||||
|
p = Project(**data)
|
||||||
|
set_people(p, payload.owners, payload.members)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
_sync_tags(db, p, payload.tags)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/projects/{pid}")
|
||||||
|
def get_project(pid: int, db: Session = Depends(get_db)):
|
||||||
|
p = db.get(Project, pid)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "项目不存在")
|
||||||
|
|
||||||
|
ups = (
|
||||||
|
db.query(Update, Period)
|
||||||
|
.join(Period, Period.id == Update.period_id)
|
||||||
|
.filter(Update.project_id == pid)
|
||||||
|
.order_by(Period.sort_key, Update.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
timeline: dict[int, dict] = {}
|
||||||
|
for u, period in ups:
|
||||||
|
t = timeline.setdefault(
|
||||||
|
period.id,
|
||||||
|
{
|
||||||
|
"period": period.to_dict(),
|
||||||
|
"updates": [],
|
||||||
|
"progresses": [],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
t["updates"].append(u.to_dict())
|
||||||
|
if u.progress is not None:
|
||||||
|
t["progresses"].append(u.progress)
|
||||||
|
|
||||||
|
tl = []
|
||||||
|
for t in timeline.values():
|
||||||
|
pr = t["progresses"]
|
||||||
|
tl.append(
|
||||||
|
{
|
||||||
|
"period": t["period"],
|
||||||
|
"updates": t["updates"],
|
||||||
|
"progress": round(max(pr), 1) if pr else None,
|
||||||
|
"status": worst_status([u["status"] for u in t["updates"]]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
tl.sort(key=lambda x: x["period"]["sort_key"])
|
||||||
|
|
||||||
|
return {
|
||||||
|
"project": p.to_dict(brief=False),
|
||||||
|
"tasks": [t.to_dict() for t in p.tasks],
|
||||||
|
"timeline": tl,
|
||||||
|
"updates": [u.to_dict() for u, _ in ups][::-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/projects/{pid}")
|
||||||
|
def patch_project(pid: int, payload: ProjectPatch, db: Session = Depends(get_db)):
|
||||||
|
p = db.get(Project, pid)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "项目不存在")
|
||||||
|
data = payload.model_dump(exclude_unset=True, exclude={"tags", "owners", "members"})
|
||||||
|
if "name" in data:
|
||||||
|
new_name = (data.get("name") or "").strip()
|
||||||
|
if not new_name:
|
||||||
|
raise HTTPException(400, "项目名称不能为空")
|
||||||
|
if new_name != p.name:
|
||||||
|
dup = (
|
||||||
|
db.query(Project)
|
||||||
|
.filter(Project.name == new_name, Project.id != pid)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if dup:
|
||||||
|
raise HTTPException(400, f"已存在同名项目「{new_name}」,请换一个名称")
|
||||||
|
data["name"] = new_name
|
||||||
|
for k, v in data.items():
|
||||||
|
setattr(p, k, v)
|
||||||
|
set_people(p, payload.owners, payload.members)
|
||||||
|
_sync_tags(db, p, payload.tags)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(p)
|
||||||
|
return p.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/projects/{pid}")
|
||||||
|
def delete_project(pid: int, db: Session = Depends(get_db)):
|
||||||
|
"""删除项目,级联删除其子任务与执行记录。"""
|
||||||
|
p = db.get(Project, pid)
|
||||||
|
if not p:
|
||||||
|
raise HTTPException(404, "项目不存在")
|
||||||
|
name = p.name
|
||||||
|
n_task = len(p.tasks)
|
||||||
|
n_upd = len(p.updates)
|
||||||
|
db.delete(p)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "name": name, "deleted_tasks": n_task, "deleted_updates": n_upd}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 分类 / 标记
|
||||||
|
@router.get("/categories")
|
||||||
|
def list_categories(db: Session = Depends(get_db)):
|
||||||
|
return [c.to_dict(with_count=True) for c in db.query(Category).order_by(Category.sort_order, Category.id).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/categories")
|
||||||
|
def create_category(payload: CategoryIn, db: Session = Depends(get_db)):
|
||||||
|
if db.query(Category).filter(Category.name == payload.name).one_or_none():
|
||||||
|
raise HTTPException(400, "同名分类已存在")
|
||||||
|
c = Category(name=payload.name, color=payload.color)
|
||||||
|
db.add(c)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(c)
|
||||||
|
return c.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/categories/{cid}")
|
||||||
|
def patch_category(cid: int, payload: CategoryIn, db: Session = Depends(get_db)):
|
||||||
|
c = db.get(Category, cid)
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(404, "分类不存在")
|
||||||
|
c.name = payload.name
|
||||||
|
c.color = payload.color
|
||||||
|
db.commit()
|
||||||
|
return c.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/categories/{cid}")
|
||||||
|
def delete_category(cid: int, db: Session = Depends(get_db)):
|
||||||
|
c = db.get(Category, cid)
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(404, "分类不存在")
|
||||||
|
db.query(Project).filter(Project.category_id == cid).update({"category_id": None})
|
||||||
|
db.delete(c)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tags")
|
||||||
|
def list_tags(db: Session = Depends(get_db)):
|
||||||
|
return [t.to_dict() for t in db.query(Tag).order_by(Tag.id).all()]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tags")
|
||||||
|
def create_tag(payload: TagIn, db: Session = Depends(get_db)):
|
||||||
|
t = db.query(Tag).filter(Tag.name == payload.name).one_or_none()
|
||||||
|
if t:
|
||||||
|
return t.to_dict()
|
||||||
|
t = Tag(name=payload.name, color=payload.color)
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return t.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/tags/{tid}")
|
||||||
|
def delete_tag(tid: int, db: Session = Depends(get_db)):
|
||||||
|
t = db.get(Tag, tid)
|
||||||
|
if not t:
|
||||||
|
raise HTTPException(404, "标记不存在")
|
||||||
|
db.delete(t)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 执行记录
|
||||||
|
@router.get("/updates")
|
||||||
|
def list_updates(
|
||||||
|
project_id: int | None = None,
|
||||||
|
period_id: int | None = None,
|
||||||
|
limit: int = Query(50, le=500),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(Update)
|
||||||
|
if project_id:
|
||||||
|
q = q.filter(Update.project_id == project_id)
|
||||||
|
if period_id:
|
||||||
|
q = q.filter(Update.period_id == period_id)
|
||||||
|
rows = q.order_by(Update.id.desc()).limit(limit).all()
|
||||||
|
return [u.to_dict(with_project=True) for u in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/updates")
|
||||||
|
def create_update(payload: UpdateIn, db: Session = Depends(get_db)):
|
||||||
|
data = payload.model_dump(exclude={"task_name"})
|
||||||
|
task_id = data.get("task_id")
|
||||||
|
if not task_id and payload.task_name:
|
||||||
|
t = (
|
||||||
|
db.query(Task)
|
||||||
|
.filter(Task.project_id == payload.project_id, Task.name == payload.task_name)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if not t:
|
||||||
|
t = Task(project_id=payload.project_id, name=payload.task_name)
|
||||||
|
db.add(t)
|
||||||
|
db.flush()
|
||||||
|
data["task_id"] = t.id
|
||||||
|
u = Update(**data)
|
||||||
|
db.add(u)
|
||||||
|
p = db.get(Project, payload.project_id)
|
||||||
|
if p and data.get("progress") is not None:
|
||||||
|
p.progress = float(data["progress"])
|
||||||
|
db.commit()
|
||||||
|
db.refresh(u)
|
||||||
|
return u.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/updates/{uid}")
|
||||||
|
def patch_update(uid: int, payload: dict, db: Session = Depends(get_db)):
|
||||||
|
u = db.get(Update, uid)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(404, "记录不存在")
|
||||||
|
for k in ("content", "summary", "progress", "status", "risk", "next_step", "owner", "task_id"):
|
||||||
|
if k in payload:
|
||||||
|
setattr(u, k, payload[k])
|
||||||
|
db.commit()
|
||||||
|
return u.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/updates/{uid}")
|
||||||
|
def delete_update(uid: int, db: Session = Depends(get_db)):
|
||||||
|
u = db.get(Update, uid)
|
||||||
|
if not u:
|
||||||
|
raise HTTPException(404, "记录不存在")
|
||||||
|
db.delete(u)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks")
|
||||||
|
def create_task(payload: dict, db: Session = Depends(get_db)):
|
||||||
|
t = Task(project_id=payload["project_id"], name=payload["name"])
|
||||||
|
db.add(t)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(t)
|
||||||
|
return t.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 设置
|
||||||
|
@router.get("/settings")
|
||||||
|
def get_settings(db: Session = Depends(get_db)):
|
||||||
|
from ..ai_parser import get_ai_settings
|
||||||
|
from ..config import AI_SETTING_KEYS
|
||||||
|
|
||||||
|
s = get_ai_settings(db)
|
||||||
|
return {k: s.get(k, "") for k in AI_SETTING_KEYS}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/settings")
|
||||||
|
def put_settings(payload: dict, db: Session = Depends(get_db)):
|
||||||
|
from ..config import AI_SETTING_KEYS
|
||||||
|
|
||||||
|
for k, v in (payload.get("values") or {}).items():
|
||||||
|
if k not in AI_SETTING_KEYS:
|
||||||
|
continue
|
||||||
|
row = db.get(Setting, k)
|
||||||
|
if not row:
|
||||||
|
row = Setting(key=k)
|
||||||
|
db.add(row)
|
||||||
|
row.value = "" if v is None else str(v)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True}
|
||||||
|
|
@ -0,0 +1,293 @@
|
||||||
|
"""人力资源库:人员维护 + 人员负载。
|
||||||
|
|
||||||
|
项目 / 定制项目的「负责人」统一取这里的姓名,因此:
|
||||||
|
· 改名时同步刷新已有项目的负责人字段;
|
||||||
|
· 被项目引用的人不允许直接删除,只能归档(归档后不再出现在下拉里)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import constants
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import (
|
||||||
|
CustomProject,
|
||||||
|
Period,
|
||||||
|
Project,
|
||||||
|
Staff,
|
||||||
|
Update,
|
||||||
|
join_people,
|
||||||
|
people_like,
|
||||||
|
split_people,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def empty_load() -> dict:
|
||||||
|
return {
|
||||||
|
"project_count": 0,
|
||||||
|
"key_project_count": 0,
|
||||||
|
"custom_count": 0,
|
||||||
|
"custom_amount": 0.0,
|
||||||
|
"custom_pending": 0.0,
|
||||||
|
"last_update": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StaffIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
group: str | None = None
|
||||||
|
affiliation: str | None = None
|
||||||
|
note: str | None = None
|
||||||
|
sort_order: int | None = None
|
||||||
|
archived: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
return load.setdefault(name, empty_load())
|
||||||
|
|
||||||
|
projects = db.query(Project).filter(Project.archived.is_(False)).all()
|
||||||
|
for p in projects:
|
||||||
|
if not p.owner:
|
||||||
|
continue
|
||||||
|
s = slot(p.owner)
|
||||||
|
s["project_count"] += 1
|
||||||
|
if p.is_key:
|
||||||
|
s["key_project_count"] += 1
|
||||||
|
|
||||||
|
# 名下项目的最近一次执行记录(判断是否长期没动静)
|
||||||
|
latest_sort: dict[str, int] = {}
|
||||||
|
ups = (
|
||||||
|
db.query(Update, Project, Period)
|
||||||
|
.join(Project, Project.id == Update.project_id)
|
||||||
|
.join(Period, Period.id == Update.period_id)
|
||||||
|
.order_by(Period.sort_key, Update.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for u, proj, period in ups:
|
||||||
|
for key in {x for x in (u.owner, proj.owner) if x}:
|
||||||
|
latest_sort[key] = period.sort_key
|
||||||
|
slot(key)["last_update"] = period.to_dict()["full_label"]
|
||||||
|
|
||||||
|
for cp in db.query(CustomProject).filter(CustomProject.archived.is_(False)).all():
|
||||||
|
if not cp.owner:
|
||||||
|
continue
|
||||||
|
s = slot(cp.owner)
|
||||||
|
s["custom_count"] += 1
|
||||||
|
s["custom_amount"] += cp.amount or 0
|
||||||
|
s["custom_pending"] += max(0.0, (cp.amount or 0) - (cp.revenue_amount or 0))
|
||||||
|
if cp.period_id:
|
||||||
|
per = db.get(Period, cp.period_id)
|
||||||
|
if per and per.sort_key >= latest_sort.get(cp.owner, 0):
|
||||||
|
latest_sort[cp.owner] = per.sort_key
|
||||||
|
s["last_update"] = per.to_dict()["full_label"]
|
||||||
|
|
||||||
|
for s in load.values():
|
||||||
|
s["custom_amount"] = round(s["custom_amount"], 2)
|
||||||
|
s["custom_pending"] = round(s["custom_pending"], 2)
|
||||||
|
return load
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/staff")
|
||||||
|
def list_staff(
|
||||||
|
archived: str = Query("false", pattern="^(true|false|all)$"),
|
||||||
|
group: str | None = None,
|
||||||
|
keyword: str | None = None,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
q = db.query(Staff)
|
||||||
|
if archived == "false":
|
||||||
|
q = q.filter(Staff.archived.is_(False))
|
||||||
|
elif archived == "true":
|
||||||
|
q = q.filter(Staff.archived.is_(True))
|
||||||
|
if group:
|
||||||
|
q = q.filter(Staff.group == group)
|
||||||
|
if keyword:
|
||||||
|
q = q.filter(Staff.name.contains(keyword) | Staff.note.contains(keyword))
|
||||||
|
members = q.order_by(Staff.sort_order, Staff.id).all()
|
||||||
|
|
||||||
|
load = _workload(db)
|
||||||
|
items = []
|
||||||
|
for m in members:
|
||||||
|
d = m.to_dict()
|
||||||
|
d.update(load.get(m.name) or empty_load())
|
||||||
|
items.append(d)
|
||||||
|
|
||||||
|
# 花名册之外的人名(历史数据 / 外部负责人),用于数据核对
|
||||||
|
known = {m.name for m in db.query(Staff).all()}
|
||||||
|
outside: dict[str, dict] = {}
|
||||||
|
for name, cnt in (
|
||||||
|
db.query(Project.owner, func.count(Project.id))
|
||||||
|
.filter(Project.archived.is_(False), Project.owner.isnot(None), Project.owner != "")
|
||||||
|
.group_by(Project.owner)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
if name not in known:
|
||||||
|
outside.setdefault(name, {"name": name, "project_count": 0, "custom_count": 0})[
|
||||||
|
"project_count"
|
||||||
|
] = cnt
|
||||||
|
for name, cnt in (
|
||||||
|
db.query(CustomProject.owner, func.count(CustomProject.id))
|
||||||
|
.filter(
|
||||||
|
CustomProject.archived.is_(False),
|
||||||
|
CustomProject.owner.isnot(None),
|
||||||
|
CustomProject.owner != "",
|
||||||
|
)
|
||||||
|
.group_by(CustomProject.owner)
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
if name not in known:
|
||||||
|
outside.setdefault(name, {"name": name, "project_count": 0, "custom_count": 0})[
|
||||||
|
"custom_count"
|
||||||
|
] = cnt
|
||||||
|
|
||||||
|
def dist(field: str, names: list[str]) -> list[dict]:
|
||||||
|
counter: dict[str, int] = {}
|
||||||
|
for m in members:
|
||||||
|
key = getattr(m, field) or "未填写"
|
||||||
|
counter[key] = counter.get(key, 0) + 1
|
||||||
|
ordered = [k for k in names if k in counter] + sorted(
|
||||||
|
k for k in counter if k not in names
|
||||||
|
)
|
||||||
|
return [{"name": k, "count": counter[k]} for k in ordered]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"items": items,
|
||||||
|
"summary": {
|
||||||
|
"total": len(items),
|
||||||
|
"groups": dist("group", constants.STAFF_GROUPS),
|
||||||
|
"affiliations": dist("affiliation", constants.STAFF_AFFILIATIONS),
|
||||||
|
"with_project": sum(1 for i in items if i["project_count"] or i["custom_count"]),
|
||||||
|
"outside": sorted(
|
||||||
|
outside.values(),
|
||||||
|
key=lambda x: -(x["project_count"] + x["custom_count"]),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"dict": {
|
||||||
|
"groups": constants.STAFF_GROUPS,
|
||||||
|
"affiliations": constants.STAFF_AFFILIATIONS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/staff")
|
||||||
|
def create_staff(payload: StaffIn, db: Session = Depends(get_db)):
|
||||||
|
name = (payload.name or "").strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(400, "请填写姓名")
|
||||||
|
if db.query(Staff).filter(Staff.name == name).one_or_none():
|
||||||
|
raise HTTPException(400, f"「{name}」已在花名册中(可能在归档里),请先归档筛选后恢复")
|
||||||
|
sort_order = payload.sort_order
|
||||||
|
if sort_order is None:
|
||||||
|
sort_order = (db.query(func.max(Staff.sort_order)).scalar() or 0) + 1
|
||||||
|
m = Staff(
|
||||||
|
name=name,
|
||||||
|
group=constants.normalize_staff_group(payload.group),
|
||||||
|
affiliation=constants.normalize_staff_affiliation(payload.affiliation),
|
||||||
|
note=(payload.note or "").strip() or None,
|
||||||
|
sort_order=sort_order,
|
||||||
|
archived=payload.archived,
|
||||||
|
)
|
||||||
|
db.add(m)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(m)
|
||||||
|
return m.to_dict()
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/staff/{sid}")
|
||||||
|
def patch_staff(sid: int, payload: dict, db: Session = Depends(get_db)):
|
||||||
|
"""修改人员信息。改名会把名下项目的负责人一并改过来。"""
|
||||||
|
m = db.get(Staff, sid)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "人员不存在")
|
||||||
|
payload = payload or {}
|
||||||
|
old_name = m.name
|
||||||
|
new_name = old_name
|
||||||
|
for k, v in payload.items():
|
||||||
|
if k not in ALLOWED_PATCH:
|
||||||
|
continue
|
||||||
|
if k == "name":
|
||||||
|
v = (str(v or "")).strip()
|
||||||
|
if not v:
|
||||||
|
raise HTTPException(400, "姓名不能为空")
|
||||||
|
dup = db.query(Staff).filter(Staff.name == v, Staff.id != sid).one_or_none()
|
||||||
|
if dup:
|
||||||
|
raise HTTPException(400, f"花名册中已有「{v}」")
|
||||||
|
new_name = v
|
||||||
|
elif k == "group":
|
||||||
|
v = constants.normalize_staff_group(v)
|
||||||
|
elif k == "affiliation":
|
||||||
|
v = constants.normalize_staff_affiliation(v)
|
||||||
|
setattr(m, k, v)
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
synced = {"projects": 0, "custom_projects": 0}
|
||||||
|
if new_name != old_name:
|
||||||
|
synced["projects"] = (
|
||||||
|
db.query(Project).filter(Project.owner == old_name).update({Project.owner: new_name}, synchronize_session=False)
|
||||||
|
)
|
||||||
|
synced["custom_projects"] = (
|
||||||
|
db.query(CustomProject)
|
||||||
|
.filter(CustomProject.owner == old_name)
|
||||||
|
.update({CustomProject.owner: new_name}, synchronize_session=False)
|
||||||
|
)
|
||||||
|
# 其他负责人 / 团队成员里的人名跟着一起改,否则改名后名单里留旧名字
|
||||||
|
for model in (Project, CustomProject):
|
||||||
|
rows = (
|
||||||
|
db.query(model)
|
||||||
|
.filter(
|
||||||
|
model.other_owners.like(people_like(old_name))
|
||||||
|
| model.members.like(people_like(old_name))
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
row.other_owners = join_people(
|
||||||
|
[new_name if x == old_name else x for x in split_people(row.other_owners)]
|
||||||
|
)
|
||||||
|
row.members = join_people(
|
||||||
|
[new_name if x == old_name else x for x in split_people(row.members)]
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(m)
|
||||||
|
return {"staff": m.to_dict(), "synced": synced}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/staff/{sid}")
|
||||||
|
def delete_staff(sid: int, force: bool = False, db: Session = Depends(get_db)):
|
||||||
|
"""删除人员。仍名下有在管项目时需要 force=true,否则提示改用归档。"""
|
||||||
|
m = db.get(Staff, sid)
|
||||||
|
if not m:
|
||||||
|
raise HTTPException(404, "人员不存在")
|
||||||
|
n1 = (
|
||||||
|
db.query(func.count(Project.id))
|
||||||
|
.filter(Project.archived.is_(False), Project.owner == m.name)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
n2 = (
|
||||||
|
db.query(func.count(CustomProject.id))
|
||||||
|
.filter(CustomProject.archived.is_(False), CustomProject.owner == m.name)
|
||||||
|
.scalar()
|
||||||
|
)
|
||||||
|
if (n1 or n2) and not force:
|
||||||
|
raise HTTPException(
|
||||||
|
400,
|
||||||
|
f"「{m.name}」名下还有 {n1} 个在管项目 / {n2} 个定制项目。"
|
||||||
|
f"离职人员建议直接「归档」(归档后不再出现在负责人下拉里,历史记录保留);"
|
||||||
|
f"确需删除请先转交这些项目。",
|
||||||
|
)
|
||||||
|
name = m.name
|
||||||
|
db.delete(m)
|
||||||
|
db.commit()
|
||||||
|
return {"ok": True, "name": name}
|
||||||
|
|
@ -0,0 +1,192 @@
|
||||||
|
"""周看板:重点项目 + 定制项目的单周总览。
|
||||||
|
|
||||||
|
左侧总览清单(左栏)+ 右侧执行详情(右栏)一次给齐:
|
||||||
|
· 重点项目 = 打了 ★ 的项目,以及本周期有执行记录的项目
|
||||||
|
· 定制项目 = 打了 ★ 的定制项目,本周期有交付记录的,以及本周期新入库的(is_new,可切「全部在途」)
|
||||||
|
前端左栏只给一个入口,点开后在右栏按「本周新增 / 本周有变化」分组展示。
|
||||||
|
右栏点开某一条时再按需拉 /projects/{id} 或 /custom/projects/{id} 看全量历史。
|
||||||
|
工作看板左栏的「本周工作重点」不在本接口里:前端并发取 /api/focus 后合并成一组。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .. import constants
|
||||||
|
from ..db import get_db
|
||||||
|
from ..models import CustomProject, CustomUpdate, Period, Project, Update, all_owners
|
||||||
|
from .board import resolve_period_ids, worst_status
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/weekly")
|
||||||
|
def weekly(
|
||||||
|
period_id: int | None = None,
|
||||||
|
year: int | None = None,
|
||||||
|
month: int | None = None,
|
||||||
|
owner: str | None = None,
|
||||||
|
all_custom: bool = False,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
period_ids = resolve_period_ids(db, period_id, year, month)
|
||||||
|
if not period_ids:
|
||||||
|
return {"periods": [], "projects": [], "custom": [], "kpi": {}}
|
||||||
|
|
||||||
|
periods = db.query(Period).filter(Period.id.in_(period_ids)).order_by(Period.sort_key).all()
|
||||||
|
scope_sort = max(p.sort_key for p in periods)
|
||||||
|
# 本周期(月视图 = 该月所有周)的日期范围,用来判断「本周期新入库」,
|
||||||
|
# 口径与「入库时间」一致:该月第一个周一算第 1 周
|
||||||
|
scope_start = min(constants.period_first_day(p.year, p.month, p.week) for p in periods)
|
||||||
|
scope_end = max(constants.period_last_day(p.year, p.month, p.week) for p in periods)
|
||||||
|
# 全部周次(算「连续几周没更新」用)
|
||||||
|
all_weeks = [p.sort_key for p in db.query(Period).order_by(Period.sort_key).all()]
|
||||||
|
week_index = {sk: i for i, sk in enumerate(all_weeks)}
|
||||||
|
|
||||||
|
def idle_weeks(last_sort: int | None) -> int | None:
|
||||||
|
if not last_sort or last_sort not in week_index:
|
||||||
|
return None
|
||||||
|
return max(0, week_index[scope_sort] - week_index[last_sort])
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 常规项目
|
||||||
|
ups_by_project: dict[int, list[Update]] = defaultdict(list)
|
||||||
|
for u in db.query(Update).filter(Update.period_id.in_(period_ids)).order_by(Update.id).all():
|
||||||
|
ups_by_project[u.project_id].append(u)
|
||||||
|
|
||||||
|
def last_seen(model, fk) -> tuple[dict[int, int], dict[int, str]]:
|
||||||
|
sorts: dict[int, int] = {}
|
||||||
|
labels: dict[int, str] = {}
|
||||||
|
rows = (
|
||||||
|
db.query(
|
||||||
|
fk, Period.sort_key, Period.year, Period.month, Period.week
|
||||||
|
)
|
||||||
|
.join(Period, Period.id == model.period_id)
|
||||||
|
.order_by(Period.sort_key, model.id)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
for oid, sk, yy, mm, ww in rows:
|
||||||
|
sorts[oid] = sk
|
||||||
|
labels[oid] = f"{yy}年{mm}月" + (f"第{ww}周" if ww else "")
|
||||||
|
return sorts, labels
|
||||||
|
|
||||||
|
last_sort, last_label = last_seen(Update, Update.project_id)
|
||||||
|
|
||||||
|
project_rows = []
|
||||||
|
for p in db.query(Project).filter(Project.archived.is_(False)).order_by(
|
||||||
|
Project.is_key.desc(), Project.sort_order, Project.id
|
||||||
|
).all():
|
||||||
|
items = ups_by_project.get(p.id, [])
|
||||||
|
if owner and owner not in all_owners(p.owner, p.other_owners):
|
||||||
|
continue
|
||||||
|
if not (p.is_key or items):
|
||||||
|
continue
|
||||||
|
progresses = [u.progress for u in items if u.progress is not None]
|
||||||
|
progress = round(max(progresses), 1) if progresses else round(p.progress or 0, 1)
|
||||||
|
project_rows.append(
|
||||||
|
{
|
||||||
|
"kind": "project",
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"owner": p.owner,
|
||||||
|
"is_key": p.is_key,
|
||||||
|
"category": p.category.to_dict() if p.category else None,
|
||||||
|
"status": worst_status([u.status for u in items]) if items else p.status,
|
||||||
|
"progress": progress,
|
||||||
|
"progress_source": "本周期" if progresses else "项目档案",
|
||||||
|
"update_count": len(items),
|
||||||
|
"has_update": bool(items),
|
||||||
|
"summary": next((u.summary for u in items if u.summary), None)
|
||||||
|
or (items[-1].content or "")[:120] if items else None,
|
||||||
|
"risks": [u.risk for u in items if u.risk],
|
||||||
|
"next_steps": [u.next_step for u in items if u.next_step],
|
||||||
|
"updates": [u.to_dict() for u in items],
|
||||||
|
"task_count": len(p.tasks),
|
||||||
|
"last_update": last_label.get(p.id),
|
||||||
|
"idle_weeks": idle_weeks(last_sort.get(p.id)),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 定制项目
|
||||||
|
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()
|
||||||
|
):
|
||||||
|
cups_by_project[u.custom_project_id].append(u)
|
||||||
|
|
||||||
|
cup_last_sort, cup_last_label = last_seen(CustomUpdate, CustomUpdate.custom_project_id)
|
||||||
|
|
||||||
|
custom_rows = []
|
||||||
|
inflight_stages = constants.CUSTOM_INFLIGHT_STAGES
|
||||||
|
for cp in db.query(CustomProject).filter(CustomProject.archived.is_(False)).all():
|
||||||
|
items = cups_by_project.get(cp.id, [])
|
||||||
|
if owner and cp.owner != owner:
|
||||||
|
continue
|
||||||
|
entry = (cp.entry_date or "").strip()
|
||||||
|
is_new = bool(scope_start and scope_end and entry and scope_start <= entry <= scope_end)
|
||||||
|
if not (cp.is_key or items or is_new):
|
||||||
|
if not all_custom or cp.stage not in inflight_stages:
|
||||||
|
continue
|
||||||
|
d = cp.to_dict()
|
||||||
|
d.update(
|
||||||
|
{
|
||||||
|
"kind": "custom",
|
||||||
|
"is_new": is_new,
|
||||||
|
"update_count": len(items),
|
||||||
|
"has_update": bool(items),
|
||||||
|
"summary": next((u.summary for u in items if u.summary), None)
|
||||||
|
or next((u.content for u in items if u.content), None),
|
||||||
|
"risks": [u.risk for u in items if u.risk],
|
||||||
|
"next_steps": [u.next_step for u in items if u.next_step],
|
||||||
|
"period_amount_delta": round(sum(u.amount_delta or 0 for u in items), 2),
|
||||||
|
"period_revenue_delta": round(sum(u.revenue_delta or 0 for u in items), 2),
|
||||||
|
"updates": [u.to_dict() for u in items],
|
||||||
|
"idle_weeks": idle_weeks(cup_last_sort.get(cp.id)),
|
||||||
|
"last_update": cup_last_label.get(cp.id),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
custom_rows.append(d)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 排序 & KPI
|
||||||
|
def sort_key(r: dict):
|
||||||
|
return (
|
||||||
|
not r["is_key"],
|
||||||
|
not r["has_update"],
|
||||||
|
r.get("status") != "blocked",
|
||||||
|
-float(r.get("progress") or 0),
|
||||||
|
r["name"],
|
||||||
|
)
|
||||||
|
|
||||||
|
project_rows.sort(key=sort_key)
|
||||||
|
custom_rows.sort(
|
||||||
|
key=lambda r: (
|
||||||
|
not r["is_key"],
|
||||||
|
not r["has_update"],
|
||||||
|
-float(r.get("amount") or 0),
|
||||||
|
r["name"],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
key_all = sum(1 for p in db.query(Project).filter(Project.archived.is_(False), Project.is_key.is_(True)).all())
|
||||||
|
risks = sum(len(r["risks"]) for r in project_rows) + sum(len(r["risks"]) for r in custom_rows)
|
||||||
|
kpi = {
|
||||||
|
"key_total": key_all,
|
||||||
|
"key_updated": sum(1 for r in project_rows if r["is_key"] and r["has_update"]),
|
||||||
|
"key_idle": sum(1 for r in project_rows if r["is_key"] and not r["has_update"]),
|
||||||
|
"project_listed": len(project_rows),
|
||||||
|
"update_count": sum(r["update_count"] for r in project_rows),
|
||||||
|
"custom_total": len(custom_rows),
|
||||||
|
"custom_new": sum(1 for r in custom_rows if r["is_new"]),
|
||||||
|
"custom_updated": sum(1 for r in custom_rows if r["has_update"]),
|
||||||
|
"custom_amount": round(sum(float(r.get("amount") or 0) for r in custom_rows), 2),
|
||||||
|
"amount_delta": round(sum(r["period_amount_delta"] for r in custom_rows), 2),
|
||||||
|
"revenue_delta": round(sum(r["period_revenue_delta"] for r in custom_rows), 2),
|
||||||
|
"risk_count": risks,
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"periods": [p.to_dict() for p in periods],
|
||||||
|
"projects": project_rows,
|
||||||
|
"custom": custom_rows,
|
||||||
|
"kpi": kpi,
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
"""老库补列:create_all 只会建新表,不会给已存在的表加字段。
|
||||||
|
|
||||||
|
模型新增可空字段后(如定制项目的验收时间 / 开票 / 回款),启动时自动
|
||||||
|
`ALTER TABLE ... ADD COLUMN` 补齐,不需要手写迁移脚本。
|
||||||
|
|
||||||
|
用法:在 `Base.metadata.create_all()` 之后调用 `ensure_columns(engine, Base.metadata)`。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import inspect, text
|
||||||
|
from sqlalchemy.engine import Engine
|
||||||
|
from sqlalchemy.sql.schema import MetaData
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_columns(engine: Engine, metadata: MetaData) -> list[str]:
|
||||||
|
"""给已存在的表补上模型里新增的可空字段,返回补过的列名(表.列)。"""
|
||||||
|
insp = inspect(engine)
|
||||||
|
added: list[str] = []
|
||||||
|
for table in metadata.tables.values():
|
||||||
|
if not insp.has_table(table.name):
|
||||||
|
continue # 表还不存在 → create_all 会按最新模型建,这里不用管
|
||||||
|
existing = {c["name"] for c in insp.get_columns(table.name)}
|
||||||
|
for col in table.columns:
|
||||||
|
if col.name in existing:
|
||||||
|
continue
|
||||||
|
if not col.nullable:
|
||||||
|
print(f"[warn] 跳过高字段 {table.name}.{col.name}:新增列需允许为空")
|
||||||
|
continue
|
||||||
|
ddl = col.type.compile(engine.dialect)
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(f"ALTER TABLE {table.name} ADD COLUMN {col.name} {ddl} NULL"))
|
||||||
|
added.append(f"{table.name}.{col.name}")
|
||||||
|
return added
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
category_id: int | None = None
|
||||||
|
owner: str | None = None # 主负责人;传了 owners 时以 owners[0] 为准
|
||||||
|
owners: list[str] | None = None # 多负责人(第一位是主负责人),整体替换
|
||||||
|
members: list[str] | None = None # 团队成员,整体替换
|
||||||
|
status: str = "in_progress"
|
||||||
|
priority: str = "p2"
|
||||||
|
is_key: bool = False
|
||||||
|
progress: float = 0
|
||||||
|
description: str | None = None
|
||||||
|
archived: bool = False
|
||||||
|
tags: list[str] = Field(default_factory=list) # 标签名,自动创建
|
||||||
|
|
||||||
|
|
||||||
|
class ProjectPatch(BaseModel):
|
||||||
|
name: str | None = None
|
||||||
|
category_id: int | None = None
|
||||||
|
owner: str | None = None
|
||||||
|
owners: list[str] | None = None # 不传即不改;传空数组即清空
|
||||||
|
members: list[str] | None = None
|
||||||
|
status: str | None = None
|
||||||
|
priority: str | None = None
|
||||||
|
is_key: bool | None = None
|
||||||
|
progress: float | None = None
|
||||||
|
description: str | None = None
|
||||||
|
archived: bool | None = None
|
||||||
|
tags: list[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CategoryIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
color: str = "#2563eb"
|
||||||
|
|
||||||
|
|
||||||
|
class TagIn(BaseModel):
|
||||||
|
name: str
|
||||||
|
color: str = "#6b7280"
|
||||||
|
|
||||||
|
|
||||||
|
class PeriodIn(BaseModel):
|
||||||
|
label: str
|
||||||
|
kind: str = "week"
|
||||||
|
year: int
|
||||||
|
month: int
|
||||||
|
week: int = 0
|
||||||
|
start_date: str | None = None
|
||||||
|
end_date: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TaskIn(BaseModel):
|
||||||
|
project_id: int
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateIn(BaseModel):
|
||||||
|
project_id: int
|
||||||
|
task_id: int | None = None
|
||||||
|
period_id: int
|
||||||
|
content: str | None = None
|
||||||
|
summary: str | None = None
|
||||||
|
progress: float | None = None
|
||||||
|
status: str = "in_progress"
|
||||||
|
risk: str | None = None
|
||||||
|
next_step: str | None = None
|
||||||
|
owner: str | None = None
|
||||||
|
source: str = "manual"
|
||||||
|
raw_text: str | None = None
|
||||||
|
model: str | None = None
|
||||||
|
# AI 写入时可直接传子任务名(自动创建/复用)
|
||||||
|
task_name: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AIParseIn(BaseModel):
|
||||||
|
project_id: int | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
period_id: int | None = None
|
||||||
|
raw_text: str
|
||||||
|
use_ai: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AICommitIn(BaseModel):
|
||||||
|
project_id: int | None = None
|
||||||
|
project_name: str | None = None
|
||||||
|
period_id: int
|
||||||
|
raw_text: str
|
||||||
|
parsed: dict[str, Any]
|
||||||
|
mode: str = "ai"
|
||||||
|
model: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsIn(BaseModel):
|
||||||
|
values: dict[str, str]
|
||||||
|
|
||||||
|
|
||||||
|
class FocusIn(BaseModel):
|
||||||
|
"""每周重点工作(单条录入)。
|
||||||
|
|
||||||
|
预计完成时间是日历日期(due_date: YYYY-MM-DD),只作目标日期,不决定条目归属的周;
|
||||||
|
period_id 是录入周(前端传顶栏当前周,脚本可不传,后端按今天所在周补)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
content: str
|
||||||
|
detail: str | None = None
|
||||||
|
due_date: str | None = None
|
||||||
|
result: str | None = None
|
||||||
|
delayed: bool | None = None
|
||||||
|
period_id: int | None = None
|
||||||
|
status: str = "not_started"
|
||||||
|
|
||||||
|
|
||||||
|
class FocusPatch(BaseModel):
|
||||||
|
content: str | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
due_date: str | None = None
|
||||||
|
result: str | None = None
|
||||||
|
delayed: bool | None = None
|
||||||
|
period_id: int | None = None
|
||||||
|
status: str | None = None
|
||||||
|
archived: bool | None = None
|
||||||
Binary file not shown.
|
|
@ -0,0 +1,25 @@
|
||||||
|
本周主要完成华润啤酒的需求开发和华智工厂的问题定位;
|
||||||
|
一、华润啤酒(开发进度70%):基本完成开发,下周合代码进行联调
|
||||||
|
(1)集群迁移 --确定新方案k3s,进度 70%
|
||||||
|
(2)网关接入(先实现了麒麟)--方案确定用nginx自己实现,网关开放8113端口,文件分发走一个端口,增加域名,网关不做集群,进度70%
|
||||||
|
(3)数据恢复工具,已提供解决方案(仅限windows)
|
||||||
|
(4)wifi二次认证未验证 --已完成开发,需要现场验证
|
||||||
|
(5)域用户模式下ip配置穿透到麒麟系统 ----开发完成
|
||||||
|
(6)系统日志收集,--开发完成
|
||||||
|
(7)管理平台支持https --开发完成
|
||||||
|
(8)服务器组raid,提供文档和验证,不涉及修改代码
|
||||||
|
(9)信创通升级增加提示,会覆盖数据 --开发完成
|
||||||
|
(10)升级明细删除 --开发完成
|
||||||
|
(11) 镜像文件路径切换 --开发完成
|
||||||
|
二、协助处理华智工厂反馈的卡死问题
|
||||||
|
(1)终端24h运行使用,本身盒子性能不高,我们提供了关闭agent的脚本,建议定时重启释放内存与清理缓存
|
||||||
|
10.虚拟应用更新缺少覆盖提醒 --主线版本开发中
|
||||||
|
三、临沂公安、龙江交投、大唐能源、佛山盘古、云南楚雄暂无进度和投入,详见表格
|
||||||
|
四、主线:
|
||||||
|
(1)center面板,开发进度100%
|
||||||
|
(2)集群部署,进度70%
|
||||||
|
(3)国产化操作系统和国产化数据库切换,进度60%
|
||||||
|
(4)授权卸载管理,开发100%
|
||||||
|
(5)内核升级,开发100%
|
||||||
|
(6)升级任务删除,开发100%
|
||||||
|
(7)系统默认用户名称修改开发100%
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,10 @@
|
||||||
|
fastapi>=0.110
|
||||||
|
uvicorn[standard]>=0.27
|
||||||
|
sqlalchemy>=2.0
|
||||||
|
pydantic>=2.0
|
||||||
|
httpx>=0.27
|
||||||
|
openpyxl>=3.1
|
||||||
|
python-multipart>=0.0.9
|
||||||
|
# 迁移到 MySQL 时额外安装:
|
||||||
|
# pymysql>=1.1
|
||||||
|
# cryptography>=42.0
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# 部门关键任务追踪看板 · 启动脚本
|
||||||
|
# 用法: ./run.sh 正常启动
|
||||||
|
# PORT=8771 ./run.sh 换端口启动
|
||||||
|
# DEV=1 ./run.sh 开发模式(改 Python/前端/.env 自动重启,浏览器自动刷新)
|
||||||
|
# NO_OPEN=1 ./run.sh 启动后不自动打开浏览器
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
PORT="${PORT:-8770}"
|
||||||
|
XLSX="${XLSX:-/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx}"
|
||||||
|
STAFF_XLSX="${STAFF_XLSX:-/Users/jiliu/WorkSpace/unissense/部门管理/软件开发部人员名单.xlsx}"
|
||||||
|
|
||||||
|
echo "==> 部门关键任务追踪看板"
|
||||||
|
|
||||||
|
# ---------- 1. 选择带依赖的 Python 解释器 ----------
|
||||||
|
CANDIDATES=(
|
||||||
|
"./.venv/bin/python"
|
||||||
|
"/Users/jiliu/.workbuddy/binaries/python/envs/default/bin/python"
|
||||||
|
"$(command -v python3 2>/dev/null || true)"
|
||||||
|
"/usr/bin/python3"
|
||||||
|
)
|
||||||
|
PY=""
|
||||||
|
FALLBACK=""
|
||||||
|
for c in "${CANDIDATES[@]}"; do
|
||||||
|
[ -n "$c" ] && [ -x "$c" ] || continue
|
||||||
|
[ -z "$FALLBACK" ] && FALLBACK="$c"
|
||||||
|
if "$c" -c "import fastapi, uvicorn, sqlalchemy" >/dev/null 2>&1; then
|
||||||
|
PY="$c"; break
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
PY="$FALLBACK"
|
||||||
|
if [ -z "$PY" ]; then
|
||||||
|
echo "!! 没有找到 Python 3,请先安装后再运行。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> 缺少依赖,正在安装到 $PY"
|
||||||
|
"$PY" -m pip install -r requirements.txt || {
|
||||||
|
echo "!! 依赖安装失败,请手动执行:" >&2
|
||||||
|
echo " $PY -m pip install -r requirements.txt" >&2
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
echo " 使用解释器:$PY"
|
||||||
|
|
||||||
|
# ---------- 2. 端口占用检查(已在运行就直接给出地址) ----------
|
||||||
|
if curl -s -o /dev/null --max-time 2 "http://127.0.0.1:${PORT}/health" 2>/dev/null; then
|
||||||
|
echo "==> 端口 ${PORT} 上已有服务在运行"
|
||||||
|
echo " 看板地址: http://127.0.0.1:${PORT}"
|
||||||
|
echo " (要重启请先在原窗口按 Ctrl+C,或执行:lsof -ti:${PORT} | xargs kill)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 3. 首次运行:建库 + 导入历史数据 ----------
|
||||||
|
if [ ! -f data/board.db ]; then
|
||||||
|
echo "==> 首次运行,初始化数据库"
|
||||||
|
"$PY" scripts/init_db.py
|
||||||
|
if [ -f "$XLSX" ]; then
|
||||||
|
echo "==> 导入历史数据:$XLSX"
|
||||||
|
"$PY" scripts/import_excel.py "$XLSX"
|
||||||
|
echo "==> 拆分定制交付项目"
|
||||||
|
"$PY" scripts/migrate_custom.py
|
||||||
|
else
|
||||||
|
echo " (未找到 Excel 源文件,跳过历史数据导入:$XLSX)"
|
||||||
|
fi
|
||||||
|
if [ -f "$STAFF_XLSX" ]; then
|
||||||
|
echo "==> 导入人员名单:$STAFF_XLSX"
|
||||||
|
"$PY" scripts/import_staff.py "$STAFF_XLSX"
|
||||||
|
else
|
||||||
|
echo " (未找到人员名单,跳过人力库导入:$STAFF_XLSX)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------- 4. 启动 ----------
|
||||||
|
# 开发模式:uvicorn 默认只监听 *.py,这里补上前端文件与 .env
|
||||||
|
RELOAD_ARGS=()
|
||||||
|
if [ "${DEV:-0}" = "1" ]; then
|
||||||
|
RELOAD_ARGS=(--reload
|
||||||
|
--reload-include "*.js" --reload-include "*.css" --reload-include "*.html"
|
||||||
|
--reload-include ".env")
|
||||||
|
fi
|
||||||
|
|
||||||
|
URL="http://127.0.0.1:${PORT}"
|
||||||
|
echo "==> 启动服务: $URL"
|
||||||
|
echo " API 文档: $URL/api/docs"
|
||||||
|
echo " 停止服务: Ctrl+C"
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [ "${NO_OPEN:-0}" != "1" ]; then
|
||||||
|
( sleep 3; open "$URL" >/dev/null 2>&1 || true ) &
|
||||||
|
fi
|
||||||
|
|
||||||
|
exec "$PY" -m uvicorn app.main:app --host 127.0.0.1 --port "$PORT" ${RELOAD_ARGS[@]+"${RELOAD_ARGS[@]}"}
|
||||||
|
|
@ -0,0 +1,200 @@
|
||||||
|
"""为定制项目补充『入库时间』字段并回填历史数据。
|
||||||
|
|
||||||
|
取值优先级(当前口径 = 原始数据的月/周第一天):
|
||||||
|
1. 原始 Excel 里该项目**最早出现**的周期 → 换算成该周期第一天
|
||||||
|
- 《周工作情况总结》中「定制开发」块下的子项目行
|
||||||
|
- 《项目明细表》按周分组的台账行(该列是稀疏填充,需向下继承)
|
||||||
|
2. 该项目在 custom_updates 里最早一条记录的周期
|
||||||
|
3. 项目自身 period_id 对应周期
|
||||||
|
4. 兜底:记录创建日期
|
||||||
|
|
||||||
|
实现说明:
|
||||||
|
- `Base.metadata.create_all()` 只建新表,**不会给已有表加列**,故此处显式 ALTER TABLE。
|
||||||
|
- Excel 里是平移前的年份,这里按 (月, 周) 匹配现有周期并取最早的一年,避免年份口径耦合。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/add_entry_date.py --dry-run
|
||||||
|
python scripts/add_entry_date.py
|
||||||
|
python scripts/add_entry_date.py --force
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import openpyxl # noqa: E402
|
||||||
|
from sqlalchemy import inspect, text # noqa: E402
|
||||||
|
|
||||||
|
from app.constants import normalize_custom_name, parse_period_label, period_first_day # noqa: E402
|
||||||
|
from app.db import SessionLocal, engine # noqa: E402
|
||||||
|
from app.models import ( # noqa: E402
|
||||||
|
Base,
|
||||||
|
CustomProject,
|
||||||
|
CustomUpdate,
|
||||||
|
Period,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_XLSX = "/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx"
|
||||||
|
COLUMN = "entry_date"
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
s = str(v).strip()
|
||||||
|
return s or None
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_column() -> bool:
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
insp = inspect(engine)
|
||||||
|
if "custom_projects" not in insp.get_table_names():
|
||||||
|
return False
|
||||||
|
cols = [c["name"] for c in insp.get_columns("custom_projects")]
|
||||||
|
if COLUMN in cols:
|
||||||
|
return False
|
||||||
|
with engine.begin() as conn:
|
||||||
|
conn.execute(text(f"ALTER TABLE custom_projects ADD COLUMN {COLUMN} VARCHAR(16)"))
|
||||||
|
print(f"[ok] 已新增字段 custom_projects.{COLUMN}")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def load_excel_periods(xlsx: str) -> dict[str, tuple[int, int]]:
|
||||||
|
"""从原始 Excel 提取『项目名 → (月, 周)』。
|
||||||
|
|
||||||
|
优先级:**《项目明细表》优先**(项目正式登记入册的地方),
|
||||||
|
其次才是《周工作情况总结》「定制开发」块(该项目在办的周次)。
|
||||||
|
因此这里先处理明细表,用 setdefault 占位。
|
||||||
|
"""
|
||||||
|
wb = openpyxl.load_workbook(xlsx, data_only=True, read_only=True)
|
||||||
|
found: dict[str, tuple[int, int]] = {}
|
||||||
|
|
||||||
|
# 1) 项目明细表:周期列向下继承(优先来源)
|
||||||
|
if "项目明细表" in wb.sheetnames:
|
||||||
|
ws2 = wb["项目明细表"]
|
||||||
|
last_label = None
|
||||||
|
for r in ws2.iter_rows(min_row=1, values_only=True):
|
||||||
|
cells = list(r) + [None] * 7
|
||||||
|
label, name = _clean(cells[1]), _clean(cells[2])
|
||||||
|
if label:
|
||||||
|
last_label = label
|
||||||
|
if not name or name in ("项目", "项目名称"):
|
||||||
|
continue
|
||||||
|
parsed = parse_period_label(last_label, 0) if last_label else None
|
||||||
|
if not parsed:
|
||||||
|
continue
|
||||||
|
clean_name, _amt = normalize_custom_name(name)
|
||||||
|
if clean_name:
|
||||||
|
found.setdefault(clean_name, (parsed[1], parsed[2]))
|
||||||
|
|
||||||
|
# 2) 周工作情况总结:「定制开发」块下的子项目(补充来源)
|
||||||
|
if "周工作情况总结" in wb.sheetnames:
|
||||||
|
ws = wb["周工作情况总结"]
|
||||||
|
cur_period = None
|
||||||
|
cur_group = None
|
||||||
|
for r in ws.iter_rows(min_row=3, values_only=True):
|
||||||
|
cols = list(r) + [None] * 6
|
||||||
|
time_v, work_v, desc_v = _clean(cols[1]), _clean(cols[2]), _clean(cols[3])
|
||||||
|
if time_v and time_v != "时间":
|
||||||
|
parsed = parse_period_label(time_v, 0)
|
||||||
|
cur_period = (parsed[1], parsed[2]) if parsed else None
|
||||||
|
cur_group = None
|
||||||
|
if work_v:
|
||||||
|
cur_group = work_v
|
||||||
|
if cur_period and cur_group == "定制开发" and desc_v:
|
||||||
|
name, _amt = normalize_custom_name(desc_v)
|
||||||
|
if name:
|
||||||
|
found.setdefault(name, cur_period)
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("xlsx", nargs="?", default=DEFAULT_XLSX)
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
ap.add_argument("--force", action="store_true", help="覆盖已有入库时间")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
ensure_column()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
periods = db.query(Period).all()
|
||||||
|
by_mw: dict[tuple[int, int], list[Period]] = defaultdict(list)
|
||||||
|
for p in periods:
|
||||||
|
by_mw[(p.month, p.week)].append(p)
|
||||||
|
for lst in by_mw.values():
|
||||||
|
lst.sort(key=lambda x: x.sort_key) # 取最早的一年
|
||||||
|
|
||||||
|
excel_map: dict[str, tuple[int, int]] = {}
|
||||||
|
try:
|
||||||
|
excel_map = load_excel_periods(args.xlsx)
|
||||||
|
print(f"[i] 从原始表格读到 {len(excel_map)} 个项目的周期归属")
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"[!] 找不到 Excel:{args.xlsx},跳过回查")
|
||||||
|
|
||||||
|
updated = skipped = 0
|
||||||
|
src_count = defaultdict(int)
|
||||||
|
samples = []
|
||||||
|
|
||||||
|
for cp in db.query(CustomProject).all():
|
||||||
|
if cp.entry_date and not args.force:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
mw = excel_map.get(cp.name)
|
||||||
|
period = by_mw[mw][0] if mw and by_mw.get(mw) else None
|
||||||
|
source = "原始表格"
|
||||||
|
|
||||||
|
if period is None:
|
||||||
|
pid = (
|
||||||
|
db.query(CustomUpdate.period_id)
|
||||||
|
.filter(CustomUpdate.custom_project_id == cp.id)
|
||||||
|
.order_by(CustomUpdate.period_id)
|
||||||
|
.limit(1)
|
||||||
|
.scalar()
|
||||||
|
) or cp.period_id
|
||||||
|
period = next((p for p in periods if p.id == pid), None)
|
||||||
|
source = "周期记录"
|
||||||
|
|
||||||
|
if period is not None:
|
||||||
|
value = period_first_day(period.year, period.month, period.week)
|
||||||
|
origin = f"{period.year}年{period.month}月第{period.week}周"
|
||||||
|
else:
|
||||||
|
value = cp.created_at.date().isoformat() if cp.created_at else None
|
||||||
|
origin = "记录创建日期"
|
||||||
|
source = "创建日期"
|
||||||
|
|
||||||
|
cp.entry_date = value
|
||||||
|
updated += 1
|
||||||
|
src_count[source] += 1
|
||||||
|
if len(samples) < 10:
|
||||||
|
samples.append((cp.name, origin, value))
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
db.rollback()
|
||||||
|
print(f"[dry-run] 将回填 {updated} 个项目(跳过已有 {skipped} 个)")
|
||||||
|
print(" 数据来源:", dict(src_count))
|
||||||
|
for name, origin, value in samples:
|
||||||
|
print(f" · {name[:32]:34s} {origin:>18s} → {value}")
|
||||||
|
return
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print(f"[ok] 已回填 {updated} 个项目(跳过已有 {skipped} 个)")
|
||||||
|
print(" 数据来源:", dict(src_count))
|
||||||
|
for name, origin, value in samples:
|
||||||
|
print(f" · {name[:32]:34s} {origin:>18s} → {value}")
|
||||||
|
with_value = db.query(CustomProject).filter(CustomProject.entry_date.isnot(None)).count()
|
||||||
|
total = db.query(CustomProject).count()
|
||||||
|
print(f"[ok] 覆盖率:{with_value}/{total}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,326 @@
|
||||||
|
#!/usr/bin/env python
|
||||||
|
"""
|
||||||
|
修复周期错位:把「8月第4周」里混入的「9月第1周」内容拆出来,并把现有
|
||||||
|
「9月第1周」的数据整体后移到「9月第2周」。
|
||||||
|
|
||||||
|
背景
|
||||||
|
----
|
||||||
|
原始 Excel《周工作情况总结》最后一次导入时,表尾只有一个「8月第4周」标签,
|
||||||
|
其内容里连着贴了两段(8月第4周 + 9月第1周)。导入脚本按 (项目, 子任务, 周期)
|
||||||
|
去重,同周期内重复出现就 append 到同一条记录,于是两周内容被揉在一起;
|
||||||
|
9月第1周因此没有独立数据。
|
||||||
|
|
||||||
|
现在 Excel 已拆成两个标签块,本脚本按 Excel 现状把数据库对齐:
|
||||||
|
|
||||||
|
8月第4周 (id=49) → 只保留 8月第4周内容
|
||||||
|
9月第1周 (id=50) → 从 8月第4周里拆出的 9月第1周内容
|
||||||
|
9月第2周 (新建) → 原来误放在 9月第1周 的数据
|
||||||
|
|
||||||
|
用法
|
||||||
|
----
|
||||||
|
python scripts/fix_period_split.py --dry-run # 预演
|
||||||
|
python scripts/fix_period_split.py # 执行
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
import openpyxl # noqa: E402
|
||||||
|
|
||||||
|
from app.constants import parse_period_label # noqa: E402
|
||||||
|
from app.db import SessionLocal # noqa: E402
|
||||||
|
from app.models import ( # noqa: E402
|
||||||
|
CustomProject,
|
||||||
|
CustomUpdate,
|
||||||
|
Period,
|
||||||
|
Project,
|
||||||
|
Task,
|
||||||
|
Update,
|
||||||
|
)
|
||||||
|
|
||||||
|
XLSX = "/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx"
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
# 读取 Excel:定位表尾最后两个周期标签块
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def load_tail_blocks(xlsx: str) -> tuple[dict, dict]:
|
||||||
|
"""返回 (block_prev, block_last),每个是 {(项目, 子任务): 内容} 的有序字典。"""
|
||||||
|
wb = openpyxl.load_workbook(xlsx, data_only=True, read_only=True)
|
||||||
|
ws = wb["周工作情况总结"]
|
||||||
|
rows = list(ws.iter_rows(min_row=3, values_only=True))
|
||||||
|
|
||||||
|
# 1) 找出所有周期标签所在的行号
|
||||||
|
marks: list[tuple[int, str]] = []
|
||||||
|
for i, r in enumerate(rows, start=3):
|
||||||
|
cells = list(r[:6]) + [None] * 6
|
||||||
|
v = cells[1]
|
||||||
|
if v is None:
|
||||||
|
continue
|
||||||
|
s = str(v).strip()
|
||||||
|
if s and s != "时间":
|
||||||
|
marks.append((i, s))
|
||||||
|
if len(marks) < 2:
|
||||||
|
raise SystemExit("Excel 里周期标签块不足 2 个")
|
||||||
|
|
||||||
|
(start_last, label_last), (start_prev, label_prev) = marks[-1], marks[-2]
|
||||||
|
end_prev = start_last - 1
|
||||||
|
print(f"Excel 表尾两个块:『{label_prev}』行 {start_prev}-{end_prev}"
|
||||||
|
f" / 『{label_last}』行 {start_last}-{len(rows) + 2}")
|
||||||
|
|
||||||
|
def parse(lo: int, hi: int) -> dict[tuple[str, str], str]:
|
||||||
|
out: dict[tuple[str, str], str] = {}
|
||||||
|
proj: str | None = None
|
||||||
|
for i in range(lo, hi + 1):
|
||||||
|
cells = list(rows[i - 3][:6]) + [None] * 6
|
||||||
|
work = str(cells[2]).strip() if cells[2] is not None else ""
|
||||||
|
desc = str(cells[3]).strip() if cells[3] is not None else ""
|
||||||
|
done = str(cells[4]).strip() if cells[4] is not None else ""
|
||||||
|
if work:
|
||||||
|
proj = work
|
||||||
|
if not (proj and desc and done):
|
||||||
|
continue
|
||||||
|
out.setdefault((proj, desc), done)
|
||||||
|
return out
|
||||||
|
|
||||||
|
return parse(start_prev, end_prev), parse(start_last, len(rows) + 2)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_progress(text: str):
|
||||||
|
from app.ai_parser import _guess_progress
|
||||||
|
|
||||||
|
return _guess_progress(text)
|
||||||
|
|
||||||
|
|
||||||
|
def guess_status(text: str):
|
||||||
|
from app.ai_parser import _guess_status
|
||||||
|
|
||||||
|
return _guess_status(text)
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------------- #
|
||||||
|
def main() -> None:
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="只打印计划,不写库")
|
||||||
|
ap.add_argument("--no-backup", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
db_path = ROOT / "data" / "board.db"
|
||||||
|
if not args.dry_run and not args.no_backup:
|
||||||
|
bak = ROOT / "data" / f"board_before_period_fix_{datetime.now():%Y%m%d_%H%M%S}.bak"
|
||||||
|
shutil.copy2(db_path, bak)
|
||||||
|
print(f"已备份 → {bak.name}\n")
|
||||||
|
|
||||||
|
prev_block, last_block = load_tail_blocks(XLSX)
|
||||||
|
print(f"\n『上一块』{(len(prev_block))} 条,『最后一块』{len(last_block)} 条")
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
p_prev = db.query(Period).filter(Period.sort_key == 20260804).one_or_none()
|
||||||
|
p_last = db.query(Period).filter(Period.sort_key == 20260901).one_or_none()
|
||||||
|
if not p_prev or not p_last:
|
||||||
|
raise SystemExit("找不到 2026年8月第4周 / 9月第1周 周期,请确认先执行过年份平移")
|
||||||
|
print(f"目标周期:8月第4周 id={p_prev.id} / 9月第1周 id={p_last.id}")
|
||||||
|
|
||||||
|
# ---------- Step 1: 新建 9月第2周 ----------
|
||||||
|
p_next = db.query(Period).filter(Period.sort_key == 20260902).one_or_none()
|
||||||
|
if not p_next:
|
||||||
|
p_next = Period(
|
||||||
|
label="9月第2周", kind="week", year=2026, month=9, week=2,
|
||||||
|
sort_key=20260902,
|
||||||
|
)
|
||||||
|
db.add(p_next)
|
||||||
|
db.flush()
|
||||||
|
print(f"\n[1] 新建周期:2026年9月第2周 id={p_next.id}")
|
||||||
|
else:
|
||||||
|
print(f"\n[1] 复用已有周期:2026年9月第2周 id={p_next.id}")
|
||||||
|
|
||||||
|
# ---------- 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)} 条")
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# ---------- Step 3: 拆分 8月第4周 ----------
|
||||||
|
print(f"\n[3] 拆分 8月第4周(把属于 9月第1周 的内容移到 id={p_last.id})")
|
||||||
|
cur_updates = {
|
||||||
|
(u.project.name, u.task.name if u.task else ""): u
|
||||||
|
for u in db.query(Update).filter(Update.period_id == p_prev.id).all()
|
||||||
|
}
|
||||||
|
plan_move, plan_new, plan_trim = [], [], []
|
||||||
|
for (proj, task_name), content_last in last_block.items():
|
||||||
|
if proj == "定制开发":
|
||||||
|
continue # 定制项目走 Step 4,不进常规表
|
||||||
|
key = (proj, task_name)
|
||||||
|
u = cur_updates.get(key)
|
||||||
|
if u is None:
|
||||||
|
plan_new.append((proj, task_name, content_last))
|
||||||
|
continue
|
||||||
|
content_prev = prev_block.get(key)
|
||||||
|
if content_prev is None:
|
||||||
|
# 该子任务只属于 9月第1周 → 整条搬走
|
||||||
|
plan_move.append((u, content_last))
|
||||||
|
else:
|
||||||
|
cur = (u.content or "").strip()
|
||||||
|
if cur == f"{content_prev}\n{content_last}".strip():
|
||||||
|
plan_trim.append((u, content_prev, content_last))
|
||||||
|
elif cur == content_last:
|
||||||
|
plan_move.append((u, content_last))
|
||||||
|
elif cur.endswith(content_last):
|
||||||
|
plan_trim.append((u, cur[: -len(content_last)].rstrip("\n").strip(),
|
||||||
|
content_last))
|
||||||
|
else:
|
||||||
|
print(f" !! 无法自动拆分 常规#{u.id} {proj}/{task_name}")
|
||||||
|
print(f" 现有内容:{cur[:90]!r}")
|
||||||
|
print(f" 期望尾部:{content_last[:90]!r}")
|
||||||
|
|
||||||
|
print(f" 整条搬走 {len(plan_move)} 条 / 拆出后新建 {len(plan_trim) + len(plan_new)} 条")
|
||||||
|
for u, _ in plan_move:
|
||||||
|
print(f" 搬走 常规#{u.id} {u.project.name}/{u.task.name}")
|
||||||
|
for u, keep, out in plan_trim:
|
||||||
|
print(f" 拆分 常规#{u.id} {u.project.name}/{u.task.name}"
|
||||||
|
f" (保留{len(keep)}字 / 拆出{len(out)}字)")
|
||||||
|
for proj, task_name, content in plan_new:
|
||||||
|
print(f" 新建 {proj}/{task_name}({len(content)}字)")
|
||||||
|
|
||||||
|
# ---------- 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}
|
||||||
|
custom_move = []
|
||||||
|
for c in cur_custom:
|
||||||
|
if c.project.name in last_names or any(
|
||||||
|
c.project.name and c.project.name in d for d in last_names
|
||||||
|
):
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# ---------- Step 5: 清理空的多余周期 ----------
|
||||||
|
junk = [
|
||||||
|
p for p in db.query(Period).all()
|
||||||
|
if p.sort_key not in (p_prev.sort_key, p_last.sort_key, p_next.sort_key)
|
||||||
|
and p.sort_key > 20260804
|
||||||
|
and db.query(Update).filter(Update.period_id == p.id).count() == 0
|
||||||
|
and db.query(CustomUpdate).filter(CustomUpdate.period_id == p.id).count() == 0
|
||||||
|
]
|
||||||
|
print(f"\n[5] 清理无记录的异常周期:{[f'{p.year}年{p.month}月第{p.week}周' for p in junk] or '无'}")
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print("\n[dry-run] 未写入数据库")
|
||||||
|
db.rollback()
|
||||||
|
return
|
||||||
|
|
||||||
|
# ================= 执行 =================
|
||||||
|
for u in moved_u:
|
||||||
|
u.period_id = p_next.id
|
||||||
|
for c in moved_c:
|
||||||
|
c.period_id = p_next.id
|
||||||
|
|
||||||
|
for u, content in plan_move:
|
||||||
|
u.period_id = p_last.id
|
||||||
|
if (u.content or "").strip() != content.strip():
|
||||||
|
u.content = content
|
||||||
|
|
||||||
|
for u, keep, out in plan_trim:
|
||||||
|
u.content = keep
|
||||||
|
u.progress = guess_progress(keep)
|
||||||
|
u.status = guess_status(keep)
|
||||||
|
db.add(Update(
|
||||||
|
project_id=u.project_id, task_id=u.task_id, period_id=p_last.id,
|
||||||
|
content=out, progress=guess_progress(out),
|
||||||
|
status=guess_status(out), source=u.source or "import",
|
||||||
|
))
|
||||||
|
|
||||||
|
for proj, task_name, content in plan_new:
|
||||||
|
p = db.query(Project).filter(Project.name == proj).one_or_none()
|
||||||
|
if not p:
|
||||||
|
print(f" !! 项目不存在,跳过:{proj}")
|
||||||
|
continue
|
||||||
|
t = db.query(Task).filter(
|
||||||
|
Task.project_id == p.id, Task.name == task_name[:255]).one_or_none()
|
||||||
|
if not t:
|
||||||
|
t = Task(project_id=p.id, name=task_name[:255], sort_order=999)
|
||||||
|
db.add(t)
|
||||||
|
db.flush()
|
||||||
|
db.add(Update(
|
||||||
|
project_id=p.id, task_id=t.id, period_id=p_last.id,
|
||||||
|
content=content, progress=guess_progress(content),
|
||||||
|
status=guess_status(content), source="import",
|
||||||
|
))
|
||||||
|
|
||||||
|
for c in custom_move:
|
||||||
|
c.period_id = p_last.id
|
||||||
|
|
||||||
|
for p in junk:
|
||||||
|
db.delete(p)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# ---------- 重算受影响项目的进度 / 状态 ----------
|
||||||
|
print("\n[6] 重算受影响项目的最新进度与状态")
|
||||||
|
touched = {u.project_id for u in moved_u} | {u.project_id for u, _ in plan_move}
|
||||||
|
for u, _, _ in plan_trim:
|
||||||
|
touched.add(u.project_id)
|
||||||
|
for pid in sorted(touched):
|
||||||
|
p = db.get(Project, pid)
|
||||||
|
ups = (db.query(Update).filter(Update.project_id == pid)
|
||||||
|
.order_by(Update.period_id.desc(), Update.id.desc()).limit(30).all())
|
||||||
|
prs = [x.progress for x in ups if x.progress is not None]
|
||||||
|
if prs:
|
||||||
|
p.progress = max(prs)
|
||||||
|
if ups:
|
||||||
|
p.status = ups[0].status
|
||||||
|
if p.status == "done" and (p.progress or 0) < 100:
|
||||||
|
p.status = "in_progress"
|
||||||
|
print(f" {p.name[:22]:24s} 进度={p.progress} 状态={p.status}")
|
||||||
|
|
||||||
|
# ---------- 重算受影响定制项目的入库时间 ----------
|
||||||
|
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}
|
||||||
|
for cid in sorted(x for x in cid_touched if x):
|
||||||
|
cp = db.get(CustomProject, cid)
|
||||||
|
ups = (db.query(CustomUpdate)
|
||||||
|
.filter(CustomUpdate.custom_project_id == cid)
|
||||||
|
.join(Period, CustomUpdate.period_id == Period.id)
|
||||||
|
.order_by(Period.sort_key).all())
|
||||||
|
if not ups:
|
||||||
|
continue
|
||||||
|
per = min(ups, key=lambda x: x.period.sort_key).period
|
||||||
|
d = period_first_day(per.year, per.month, per.week)
|
||||||
|
old = cp.entry_date
|
||||||
|
if cp.entry_date is None or d < cp.entry_date:
|
||||||
|
cp.entry_date = d
|
||||||
|
print(f" {cp.name[:24]:26s} {old} → {cp.entry_date}")
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("\n✅ 完成")
|
||||||
|
|
||||||
|
# ---------- 校验 ----------
|
||||||
|
print("\n[校验] 各周期记录数:")
|
||||||
|
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}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,280 @@
|
||||||
|
"""把《软件开发部管理工作执行表.xlsx》导入数据库。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/import_excel.py [excel路径] [--reset]
|
||||||
|
|
||||||
|
解析规则:
|
||||||
|
- 「周工作情况总结」:时间(周期) → 工作内容(项目) → 内容说明(子任务) → 完成情况(执行记录)
|
||||||
|
* 三列均为稀疏填充,逐级向下继承(forward fill)
|
||||||
|
* 仅填「完成情况」的行视为上一条记录的续行,追加到同一条执行记录
|
||||||
|
- 「项目明细表」:客户项目台账(区域 / 金额 / 类别 / 状态说明)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import argparse # noqa: E402
|
||||||
|
|
||||||
|
import openpyxl # noqa: E402
|
||||||
|
|
||||||
|
from app.ai_parser import _guess_progress, _guess_status # noqa: E402
|
||||||
|
from app.constants import LEDGER_CATEGORIES, parse_period_label # noqa: E402
|
||||||
|
from app.db import SessionLocal, engine # noqa: E402
|
||||||
|
from app.models import ( # noqa: E402
|
||||||
|
Base,
|
||||||
|
Category,
|
||||||
|
LedgerItem,
|
||||||
|
Period,
|
||||||
|
Project,
|
||||||
|
Task,
|
||||||
|
Update,
|
||||||
|
)
|
||||||
|
|
||||||
|
DEFAULT_XLSX = "/Users/jiliu/WorkSpace/定开管理/软件开发部管理工作执行表.xlsx"
|
||||||
|
|
||||||
|
# 重点工作流(自动打上重点标记)
|
||||||
|
KEY_PROJECTS = {"NEX云桌面", "AI Agent", "智飞无人机平台", "定制开发", "云屏"}
|
||||||
|
|
||||||
|
# 分类归属启发式
|
||||||
|
CATEGORY_RULES = [
|
||||||
|
(("无人机", "智飞"), "智能装备"),
|
||||||
|
(("AI", "智能体", "Agent", "大模型"), "AI 能力"),
|
||||||
|
(("NEX", "云桌面", "云屏", "平台开发", "UniFlow"), "平台产品"),
|
||||||
|
(("定制", "交付", "项目支撑"), "定制交付"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def guess_category(name: str) -> str | None:
|
||||||
|
for keys, cat in CATEGORY_RULES:
|
||||||
|
if any(k.lower() in name.lower() for k in keys):
|
||||||
|
return cat
|
||||||
|
return "内部运营"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("xlsx", nargs="?", default=DEFAULT_XLSX)
|
||||||
|
ap.add_argument("--reset", action="store_true", help="清空业务表后重新导入")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
if args.reset:
|
||||||
|
for model in (Update, Task, LedgerItem, Project, Period):
|
||||||
|
db.query(model).delete()
|
||||||
|
db.commit()
|
||||||
|
print("[i] 已清空历史业务数据")
|
||||||
|
|
||||||
|
cats = {c.name: c for c in db.query(Category).all()}
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(args.xlsx, data_only=True, read_only=True)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 周工作情况总结
|
||||||
|
ws = wb["周工作情况总结"]
|
||||||
|
rows = list(ws.iter_rows(min_row=3, values_only=True))
|
||||||
|
|
||||||
|
year_hint = 2024
|
||||||
|
prev_month = None
|
||||||
|
period: Period | None = None
|
||||||
|
project: Project | None = None
|
||||||
|
task: Task | None = None
|
||||||
|
update: Update | None = None
|
||||||
|
|
||||||
|
def get_period(label: str) -> Period | None:
|
||||||
|
nonlocal year_hint, prev_month
|
||||||
|
parsed = parse_period_label(label, year_hint)
|
||||||
|
if not parsed:
|
||||||
|
return None
|
||||||
|
year, month, week = parsed
|
||||||
|
if prev_month is not None and month < prev_month:
|
||||||
|
year_hint += 1
|
||||||
|
year = year_hint
|
||||||
|
prev_month = month
|
||||||
|
sort_key = year * 10000 + month * 100 + week
|
||||||
|
p = db.query(Period).filter(Period.sort_key == sort_key).one_or_none()
|
||||||
|
if not p:
|
||||||
|
p = Period(
|
||||||
|
label=str(label).strip(),
|
||||||
|
kind="week",
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
week=week,
|
||||||
|
sort_key=sort_key,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
return p
|
||||||
|
|
||||||
|
def get_project(name: str) -> Project:
|
||||||
|
p = db.query(Project).filter(Project.name == name).one_or_none()
|
||||||
|
if p:
|
||||||
|
return p
|
||||||
|
cat_name = guess_category(name)
|
||||||
|
p = Project(
|
||||||
|
name=name,
|
||||||
|
category_id=cats[cat_name].id if cat_name in cats else None,
|
||||||
|
is_key=name in KEY_PROJECTS,
|
||||||
|
status="in_progress",
|
||||||
|
progress=0,
|
||||||
|
)
|
||||||
|
db.add(p)
|
||||||
|
db.flush()
|
||||||
|
return p
|
||||||
|
|
||||||
|
n_period = n_project = n_task = n_update = 0
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
cells = list(r[:6]) + [None] * (6 - min(6, len(r)))
|
||||||
|
_, time_v, work_v, desc_v, done_v, _remark = cells
|
||||||
|
|
||||||
|
def clean(v):
|
||||||
|
if v is None:
|
||||||
|
return None
|
||||||
|
s = str(v).strip()
|
||||||
|
return s or None
|
||||||
|
|
||||||
|
time_v, work_v, desc_v, done_v = clean(time_v), clean(work_v), clean(desc_v), clean(done_v)
|
||||||
|
|
||||||
|
if time_v and time_v != "时间":
|
||||||
|
period = get_period(time_v)
|
||||||
|
if period:
|
||||||
|
n_period += 1
|
||||||
|
project = task = update = None
|
||||||
|
|
||||||
|
if work_v:
|
||||||
|
project = get_project(work_v)
|
||||||
|
n_project += 1
|
||||||
|
task = update = None
|
||||||
|
|
||||||
|
if not period or not project:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if desc_v:
|
||||||
|
task = (
|
||||||
|
db.query(Task)
|
||||||
|
.filter(Task.project_id == project.id, Task.name == desc_v[:255])
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if not task:
|
||||||
|
task = Task(project_id=project.id, name=desc_v[:255], sort_order=n_task)
|
||||||
|
db.add(task)
|
||||||
|
db.flush()
|
||||||
|
n_task += 1
|
||||||
|
update = None
|
||||||
|
|
||||||
|
if done_v:
|
||||||
|
if update is None and task is not None:
|
||||||
|
update = (
|
||||||
|
db.query(Update)
|
||||||
|
.filter(
|
||||||
|
Update.project_id == project.id,
|
||||||
|
Update.task_id == task.id,
|
||||||
|
Update.period_id == period.id,
|
||||||
|
)
|
||||||
|
.one_or_none()
|
||||||
|
)
|
||||||
|
if not update:
|
||||||
|
update = Update(
|
||||||
|
project_id=project.id,
|
||||||
|
task_id=task.id,
|
||||||
|
period_id=period.id,
|
||||||
|
content=done_v,
|
||||||
|
progress=_guess_progress(done_v),
|
||||||
|
status=_guess_status(done_v),
|
||||||
|
source="import",
|
||||||
|
)
|
||||||
|
db.add(update)
|
||||||
|
db.flush()
|
||||||
|
n_update += 1
|
||||||
|
else:
|
||||||
|
update.content = (update.content or "") + "\n" + done_v
|
||||||
|
elif update is not None:
|
||||||
|
update.content = (update.content or "") + "\n" + done_v
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# 回填项目最新进度 / 状态;长期无更新的历史一次性事项自动归档
|
||||||
|
recent_ids = [
|
||||||
|
p.id for p in db.query(Period).order_by(Period.sort_key.desc()).limit(10).all()
|
||||||
|
]
|
||||||
|
for p in db.query(Project).all():
|
||||||
|
ups = (
|
||||||
|
db.query(Update)
|
||||||
|
.filter(Update.project_id == p.id)
|
||||||
|
.order_by(Update.period_id.desc(), Update.id.desc())
|
||||||
|
.limit(20)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
prs = [u.progress for u in ups if u.progress is not None]
|
||||||
|
if prs:
|
||||||
|
p.progress = max(prs)
|
||||||
|
if ups:
|
||||||
|
p.status = ups[0].status
|
||||||
|
# 进度未满 100% 时不标记为已完成,避免与进度条矛盾
|
||||||
|
if p.status == "done" and (p.progress or 0) < 100:
|
||||||
|
p.status = "in_progress"
|
||||||
|
if not p.is_key and not any(u.period_id in recent_ids for u in ups):
|
||||||
|
p.archived = True
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------- 项目明细表
|
||||||
|
ws2 = wb["项目明细表"]
|
||||||
|
n_ledger = 0
|
||||||
|
all_periods = db.query(Period).all()
|
||||||
|
period_index = {(p.month, p.week): p for p in all_periods}
|
||||||
|
|
||||||
|
for r in ws2.iter_rows(min_row=1, values_only=True):
|
||||||
|
cells = list(r) + [None] * (7 - min(7, len(r)))
|
||||||
|
_, p_label, name, region, amount, category, note = cells[:7]
|
||||||
|
if not name or not str(name).strip():
|
||||||
|
continue
|
||||||
|
name = str(name).strip()
|
||||||
|
if name in ("项目", "项目名称"):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
amt = float(amount) if isinstance(amount, (int, float)) else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
amt = None
|
||||||
|
cat = str(category).strip() if category else None
|
||||||
|
if cat and cat not in LEDGER_CATEGORIES:
|
||||||
|
cat = None
|
||||||
|
label = str(p_label).strip() if p_label else None
|
||||||
|
parsed = parse_period_label(label, 2025) if label else None
|
||||||
|
pid = None
|
||||||
|
if parsed:
|
||||||
|
pid = period_index.get((parsed[1], parsed[2]))
|
||||||
|
pid = pid.id if pid else None
|
||||||
|
if db.query(LedgerItem).filter(
|
||||||
|
LedgerItem.name == name,
|
||||||
|
LedgerItem.period_label == label,
|
||||||
|
LedgerItem.category == cat,
|
||||||
|
).one_or_none():
|
||||||
|
continue
|
||||||
|
db.add(
|
||||||
|
LedgerItem(
|
||||||
|
name=name,
|
||||||
|
region=str(region).strip() if region else None,
|
||||||
|
amount=amt,
|
||||||
|
category=cat,
|
||||||
|
status_note=str(note).strip() if note else None,
|
||||||
|
period_label=label,
|
||||||
|
period_id=pid,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
n_ledger += 1
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"[ok] 导入完成:周期 {len(db.query(Period).all())} 个 / 项目 {len(db.query(Project).all())} 个 / "
|
||||||
|
f"子任务 {n_task} 个 / 执行记录 {len(db.query(Update).all())} 条 / 台账 {n_ledger} 条"
|
||||||
|
)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""导入《软件开发部人员名单.xlsx》→ 人力资源库(staff 表)。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/import_staff.py # 用默认路径
|
||||||
|
python scripts/import_staff.py <xlsx> # 指定文件
|
||||||
|
python scripts/import_staff.py --reset # 清空后重新导入
|
||||||
|
python scripts/import_staff.py --dry-run # 只看解析结果,不落库
|
||||||
|
|
||||||
|
表头按名称匹配:序号 / 姓名 / 归属小组 / 人员归属 / 说明。
|
||||||
|
同名人员默认跳过(不动已有记录,避免覆盖页面上的后续编辑)。
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app import constants # noqa: E402
|
||||||
|
from app.db import Base, engine, SessionLocal # noqa: E402
|
||||||
|
from app.models import Staff # noqa: E402
|
||||||
|
from app.schema_patch import ensure_columns # noqa: E402
|
||||||
|
|
||||||
|
DEFAULT_XLSX = "/Users/jiliu/WorkSpace/unissense/部门管理/软件开发部人员名单.xlsx"
|
||||||
|
COLUMNS = {"序号": "sort_order", "姓名": "name", "归属小组": "group", "人员归属": "affiliation", "说明": "note"}
|
||||||
|
|
||||||
|
|
||||||
|
def read_rows(path: Path) -> list[dict]:
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
wb = openpyxl.load_workbook(path, data_only=True)
|
||||||
|
ws = wb.worksheets[0]
|
||||||
|
rows = list(ws.iter_rows(values_only=True))
|
||||||
|
|
||||||
|
head_idx, head = None, {}
|
||||||
|
for i, row in enumerate(rows):
|
||||||
|
cells = [str(c).strip() if c is not None else "" for c in row]
|
||||||
|
if "姓名" in cells:
|
||||||
|
head_idx = i
|
||||||
|
head = {name: j for j, name in enumerate(cells) if name}
|
||||||
|
break
|
||||||
|
if head_idx is None:
|
||||||
|
raise SystemExit(f"没在 {path} 里找到含「姓名」的表头行")
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for row in rows[head_idx + 1:]:
|
||||||
|
rec = {}
|
||||||
|
for cn, field in COLUMNS.items():
|
||||||
|
j = head.get(cn)
|
||||||
|
rec[field] = None if j is None or j >= len(row) else row[j]
|
||||||
|
name = str(rec.get("name") or "").strip()
|
||||||
|
if not name:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
sort_order = int(rec.get("sort_order") or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
sort_order = 0
|
||||||
|
items.append(
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"group": constants.normalize_staff_group(str(rec.get("group") or "").strip()),
|
||||||
|
"affiliation": constants.normalize_staff_affiliation(str(rec.get("affiliation") or "").strip()),
|
||||||
|
"note": str(rec.get("note") or "").strip() or None,
|
||||||
|
"sort_order": sort_order,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("xlsx", nargs="?", default=DEFAULT_XLSX)
|
||||||
|
ap.add_argument("--reset", action="store_true", help="先清空 staff 表")
|
||||||
|
ap.add_argument("--dry-run", action="store_true", help="只解析不落库")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
path = Path(args.xlsx)
|
||||||
|
if not path.exists():
|
||||||
|
raise SystemExit(f"文件不存在:{path}")
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
ensure_columns(engine, Base.metadata)
|
||||||
|
items = read_rows(path)
|
||||||
|
print(f"[i] 解析到 {len(items)} 人:{path}")
|
||||||
|
for it in items:
|
||||||
|
print(f" {it['sort_order']:>3} {it['name']:<6} {it['group'] or '-':<10} {it['affiliation'] or '-':<4} {it['note'] or ''}")
|
||||||
|
if args.dry_run:
|
||||||
|
print("[i] dry-run,未写入")
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
if args.reset:
|
||||||
|
n = db.query(Staff).delete()
|
||||||
|
print(f"[i] 已清空原有人员 {n} 条")
|
||||||
|
added = skipped = 0
|
||||||
|
for it in items:
|
||||||
|
if db.query(Staff).filter(Staff.name == it["name"]).one_or_none():
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
db.add(Staff(**it))
|
||||||
|
added += 1
|
||||||
|
db.commit()
|
||||||
|
print(f"[ok] 新增 {added} 人,跳过重名 {skipped} 人,花名册共 {db.query(Staff).count()} 人")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
"""初始化数据库结构 + 预置分类。
|
||||||
|
|
||||||
|
用法:python scripts/init_db.py
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.constants import DEFAULT_CATEGORY_COLORS # noqa: E402
|
||||||
|
from app.db import SessionLocal, engine # noqa: E402
|
||||||
|
from app.models import Base, Category # noqa: E402
|
||||||
|
from app.schema_patch import ensure_columns # noqa: E402
|
||||||
|
|
||||||
|
DEFAULT_CATEGORIES = [
|
||||||
|
("平台产品", 0),
|
||||||
|
("AI 能力", 1),
|
||||||
|
("智能装备", 2),
|
||||||
|
("定制交付", 3),
|
||||||
|
("内部运营", 4),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
added = ensure_columns(engine, Base.metadata)
|
||||||
|
if added:
|
||||||
|
print(f"[ok] 已补齐字段:{', '.join(added)}")
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
for idx, (name, order) in enumerate(DEFAULT_CATEGORIES):
|
||||||
|
if db.query(Category).filter(Category.name == name).one_or_none():
|
||||||
|
continue
|
||||||
|
db.add(
|
||||||
|
Category(
|
||||||
|
name=name,
|
||||||
|
color=DEFAULT_CATEGORY_COLORS[idx % len(DEFAULT_CATEGORY_COLORS)],
|
||||||
|
sort_order=order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
print(f"[ok] 数据库已初始化:{engine.url}")
|
||||||
|
print(f"[ok] 预置分类:{', '.join(n for n, _ in DEFAULT_CATEGORIES)}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,171 @@
|
||||||
|
"""把通用项目 A 的全部数据合并到项目 B,然后删除 A。
|
||||||
|
|
||||||
|
合并规则:
|
||||||
|
- 子任务同名 → 归并到 B 的已有子任务,A 的执行记录改挂到该子任务
|
||||||
|
- 子任务仅在 A 存在 → 直接把该子任务转到 B 名下(保留名称)
|
||||||
|
- A 的分类 / 负责人 / 描述 / 标签:仅当 B 缺失时继承
|
||||||
|
- 合并后按最新执行记录回算 B 的进度与状态(进度未满 100% 时不会显示为已完成)
|
||||||
|
|
||||||
|
实现注意(踩过的坑):
|
||||||
|
- 项目/子任务的 relationship 都配了级联,直接 `db.delete(task)` 会把它名下的执行记录
|
||||||
|
外键置空。因此执行记录的改挂一律走 bulk UPDATE,不经过 ORM 关系。
|
||||||
|
- session 是 autoflush=False,改完必须显式 flush,否则后续统计读到的是旧数据。
|
||||||
|
- 删除源项目前要 expire_all(),避免 identity map 里的旧关系对象被级联误删。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/merge_project.py --from "智空无人机平台" --to "智飞无人机平台" --dry-run
|
||||||
|
python scripts/merge_project.py --from "智空无人机平台" --to "智飞无人机平台"
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.db import SessionLocal # noqa: E402
|
||||||
|
from app.models import Project, Task, Update # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--from", dest="src", required=True, help="被合并的项目名")
|
||||||
|
ap.add_argument("--to", dest="dst", required=True, help="目标项目名")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
src = db.query(Project).filter(Project.name == args.src).one_or_none()
|
||||||
|
dst = db.query(Project).filter(Project.name == args.dst).one_or_none()
|
||||||
|
if not src:
|
||||||
|
print(f"!! 找不到项目「{args.src}」")
|
||||||
|
return
|
||||||
|
if not dst:
|
||||||
|
print(f"!! 找不到项目「{args.dst}」")
|
||||||
|
return
|
||||||
|
if src.id == dst.id:
|
||||||
|
print("!! 源与目标是同一个项目")
|
||||||
|
return
|
||||||
|
|
||||||
|
src_id, dst_id = src.id, dst.id
|
||||||
|
n_src_tasks = db.query(Task).filter(Task.project_id == src_id).count()
|
||||||
|
n_src_ups = db.query(Update).filter(Update.project_id == src_id).count()
|
||||||
|
n_dst_tasks = db.query(Task).filter(Task.project_id == dst_id).count()
|
||||||
|
n_dst_ups = db.query(Update).filter(Update.project_id == dst_id).count()
|
||||||
|
|
||||||
|
print(f"合并: {src.name} (id={src_id}) → {dst.name} (id={dst_id})")
|
||||||
|
print(f" 源:{n_src_tasks} 个子任务 / {n_src_ups} 条执行记录")
|
||||||
|
print(f" 目标:{n_dst_tasks} 个子任务 / {n_dst_ups} 条执行记录")
|
||||||
|
print("-" * 62)
|
||||||
|
|
||||||
|
dst_map = {
|
||||||
|
t.name: t for t in db.query(Task).filter(Task.project_id == dst_id).all()
|
||||||
|
}
|
||||||
|
merged, moved = [], []
|
||||||
|
|
||||||
|
for t in db.query(Task).filter(Task.project_id == src_id).order_by(Task.sort_order, Task.id).all():
|
||||||
|
n = db.query(Update).filter(Update.task_id == t.id).count()
|
||||||
|
target = dst_map.get(t.name)
|
||||||
|
if target:
|
||||||
|
db.query(Update).filter(Update.task_id == t.id).update(
|
||||||
|
{"task_id": target.id, "project_id": dst_id}, synchronize_session=False
|
||||||
|
)
|
||||||
|
merged.append((t.name, n))
|
||||||
|
db.flush() # 先落库,再删子任务,避免外键被置空
|
||||||
|
db.delete(t)
|
||||||
|
else:
|
||||||
|
db.query(Update).filter(Update.task_id == t.id).update(
|
||||||
|
{"project_id": dst_id}, synchronize_session=False
|
||||||
|
)
|
||||||
|
t.project_id = dst_id
|
||||||
|
dst_map[t.name] = t
|
||||||
|
moved.append((t.name, n))
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 源项目下没挂子任务的记录
|
||||||
|
orphans = db.query(Update).filter(Update.project_id == src_id).all()
|
||||||
|
orphan_updates = len(orphans)
|
||||||
|
for u in orphans:
|
||||||
|
u.project_id = dst_id
|
||||||
|
|
||||||
|
# 补齐目标项目缺失的元信息
|
||||||
|
inherited = []
|
||||||
|
if not dst.category_id and src.category_id:
|
||||||
|
dst.category_id = src.category_id
|
||||||
|
inherited.append("分类")
|
||||||
|
if not dst.owner and src.owner:
|
||||||
|
dst.owner = src.owner
|
||||||
|
inherited.append("负责人")
|
||||||
|
if not dst.description and src.description:
|
||||||
|
dst.description = src.description
|
||||||
|
inherited.append("描述")
|
||||||
|
if src.is_key and not dst.is_key:
|
||||||
|
dst.is_key = True
|
||||||
|
inherited.append("重点标记")
|
||||||
|
existing_tags = {t.name for t in dst.tags}
|
||||||
|
for tag in src.tags:
|
||||||
|
if tag.name not in existing_tags:
|
||||||
|
dst.tags.append(tag)
|
||||||
|
inherited.append(f"标签:{tag.name}")
|
||||||
|
|
||||||
|
db.flush()
|
||||||
|
|
||||||
|
# 回算目标项目的进度与状态
|
||||||
|
ups = (
|
||||||
|
db.query(Update)
|
||||||
|
.filter(Update.project_id == dst_id)
|
||||||
|
.order_by(Update.period_id.desc(), Update.id.desc())
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
prs = [u.progress for u in ups if u.progress is not None]
|
||||||
|
if prs:
|
||||||
|
dst.progress = max(prs)
|
||||||
|
if ups:
|
||||||
|
dst.status = ups[0].status
|
||||||
|
if dst.status == "done" and (dst.progress or 0) < 100:
|
||||||
|
dst.status = "in_progress"
|
||||||
|
dst_name = dst.name
|
||||||
|
final_progress = dst.progress
|
||||||
|
final_status = dst.status
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
db.rollback()
|
||||||
|
print(f"[dry-run] 归并同名子任务 {len(merged)} 个、迁移子任务 {len(moved)} 个、"
|
||||||
|
f"转挂直属记录 {orphan_updates} 条")
|
||||||
|
print("[dry-run] 未写入数据库")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 清掉缓存中的旧关系对象,避免删源项目时级联误删已转挂的数据
|
||||||
|
db.expire_all()
|
||||||
|
src = db.query(Project).filter(Project.id == src_id).one()
|
||||||
|
remain_task = db.query(Task).filter(Task.project_id == src_id).count()
|
||||||
|
remain_upd = db.query(Update).filter(Update.project_id == src_id).count()
|
||||||
|
if remain_task or remain_upd:
|
||||||
|
db.rollback()
|
||||||
|
print(f"!! 源项目仍有 {remain_task} 个子任务 / {remain_upd} 条记录,已回滚")
|
||||||
|
return
|
||||||
|
db.delete(src)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print(f"归并同名子任务 {len(merged)} 个:")
|
||||||
|
for name, n in merged:
|
||||||
|
print(f" · {name[:38]:40s} 并入 {n} 条记录")
|
||||||
|
print(f"迁移独有子任务 {len(moved)} 个")
|
||||||
|
if orphan_updates:
|
||||||
|
print(f"转挂源项目直属记录 {orphan_updates} 条")
|
||||||
|
if inherited:
|
||||||
|
print("继承到目标项目:", "、".join(inherited))
|
||||||
|
print("-" * 62)
|
||||||
|
print(f"[ok] 已删除「{args.src}」")
|
||||||
|
print(f"[ok] 「{dst_name}」现有 {db.query(Task).filter(Task.project_id == dst_id).count()} 个子任务 / "
|
||||||
|
f"{db.query(Update).filter(Update.project_id == dst_id).count()} 条执行记录,"
|
||||||
|
f"进度 {final_progress:.0f}% / 状态 {final_status}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,170 @@
|
||||||
|
"""把「定制开发」从通用项目体系拆出,独立成定制交付项目。
|
||||||
|
|
||||||
|
数据来源:
|
||||||
|
1. 《项目明细表》台账(区域 / 金额 / 类别 / 状态说明)
|
||||||
|
2. 原「定制开发」项目下的子任务(子项目名,部分自带金额)与各周期执行记录
|
||||||
|
|
||||||
|
产出:custom_projects + custom_updates 两张独立表;
|
||||||
|
原 projects / tasks / updates 中的「定制开发」整体移除,常规看板只留平台产品类项目。
|
||||||
|
|
||||||
|
用法:python scripts/migrate_custom.py [--dry-run]
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
import argparse # noqa: E402
|
||||||
|
|
||||||
|
from app.ai_parser import _guess_progress # noqa: E402
|
||||||
|
from app.constants import ( # noqa: E402
|
||||||
|
CUSTOM_STAGE_CODES,
|
||||||
|
guess_custom_stage,
|
||||||
|
infer_region,
|
||||||
|
normalize_custom_name,
|
||||||
|
)
|
||||||
|
from app.db import SessionLocal, engine # noqa: E402
|
||||||
|
from app.models import ( # noqa: E402
|
||||||
|
Base,
|
||||||
|
CustomProject,
|
||||||
|
CustomUpdate,
|
||||||
|
LedgerItem,
|
||||||
|
Period,
|
||||||
|
Project,
|
||||||
|
)
|
||||||
|
|
||||||
|
CONTAINER = "定制开发"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
db = SessionLocal()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 台账
|
||||||
|
by_name: dict[str, CustomProject] = {}
|
||||||
|
created_from_ledger = 0
|
||||||
|
for it in db.query(LedgerItem).order_by(LedgerItem.id).all():
|
||||||
|
clean, amt = normalize_custom_name(it.name)
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
key = clean
|
||||||
|
cp = by_name.get(key)
|
||||||
|
if cp is None:
|
||||||
|
cp = CustomProject(
|
||||||
|
name=clean,
|
||||||
|
region=it.region,
|
||||||
|
amount=it.amount if it.amount else amt,
|
||||||
|
stage=guess_custom_stage(it.status_note, it.category),
|
||||||
|
progress=0,
|
||||||
|
remark=it.status_note,
|
||||||
|
period_id=it.period_id,
|
||||||
|
is_key=(it.category == "重点项目"),
|
||||||
|
source="import",
|
||||||
|
)
|
||||||
|
db.add(cp)
|
||||||
|
db.flush()
|
||||||
|
by_name[key] = cp
|
||||||
|
created_from_ledger += 1
|
||||||
|
else:
|
||||||
|
if not cp.amount and (it.amount or amt):
|
||||||
|
cp.amount = it.amount or amt
|
||||||
|
if not cp.region and it.region:
|
||||||
|
cp.region = it.region
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 原「定制开发」子任务
|
||||||
|
container = db.query(Project).filter(Project.name == CONTAINER).one_or_none()
|
||||||
|
created_from_task = 0
|
||||||
|
moved_updates = 0
|
||||||
|
|
||||||
|
if container:
|
||||||
|
for t in sorted(container.tasks, key=lambda x: (x.sort_order, x.id)):
|
||||||
|
clean, amt = normalize_custom_name(t.name)
|
||||||
|
if not clean:
|
||||||
|
continue
|
||||||
|
cp = by_name.get(clean) or by_name.get(t.name)
|
||||||
|
if cp is None:
|
||||||
|
cp = CustomProject(
|
||||||
|
name=clean,
|
||||||
|
amount=amt,
|
||||||
|
stage="signed",
|
||||||
|
progress=0,
|
||||||
|
source="import",
|
||||||
|
)
|
||||||
|
db.add(cp)
|
||||||
|
db.flush()
|
||||||
|
by_name[clean] = cp
|
||||||
|
created_from_task += 1
|
||||||
|
elif not cp.amount and amt:
|
||||||
|
cp.amount = amt
|
||||||
|
|
||||||
|
# 该子项目的各周期执行记录 → custom_updates
|
||||||
|
for u in t.updates:
|
||||||
|
stage = guess_custom_stage(u.content)
|
||||||
|
cu = CustomUpdate(
|
||||||
|
custom_project_id=cp.id,
|
||||||
|
period_id=u.period_id,
|
||||||
|
stage=stage,
|
||||||
|
content=u.content,
|
||||||
|
summary=u.summary,
|
||||||
|
progress=u.progress,
|
||||||
|
risk=u.risk,
|
||||||
|
next_step=u.next_step,
|
||||||
|
owner=u.owner,
|
||||||
|
source="import",
|
||||||
|
)
|
||||||
|
db.add(cu)
|
||||||
|
moved_updates += 1
|
||||||
|
# 用最新记录回写项目状态
|
||||||
|
if cp.period_id is None or u.period_id >= cp.period_id:
|
||||||
|
cp.period_id = u.period_id
|
||||||
|
cp.stage = stage
|
||||||
|
cp.remark = (u.content or "")[:500]
|
||||||
|
if u.progress is not None:
|
||||||
|
cp.progress = u.progress
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ 清理常规表
|
||||||
|
if not args.dry_run:
|
||||||
|
db.delete(container)
|
||||||
|
db.query(LedgerItem).delete()
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- 收尾
|
||||||
|
for cp in by_name.values():
|
||||||
|
if cp.stage not in CUSTOM_STAGE_CODES:
|
||||||
|
cp.stage = "signed"
|
||||||
|
if cp.stage in ("delivered", "accepted", "revenue", "closed") and not cp.progress:
|
||||||
|
cp.progress = 100
|
||||||
|
elif cp.stage == "developing" and not cp.progress:
|
||||||
|
cp.progress = 30
|
||||||
|
if not cp.region:
|
||||||
|
cp.region = infer_region(cp.name)
|
||||||
|
if cp.remark and not cp.progress:
|
||||||
|
p = _guess_progress(cp.remark)
|
||||||
|
if p is not None:
|
||||||
|
cp.progress = p
|
||||||
|
|
||||||
|
total = len(by_name)
|
||||||
|
if args.dry_run:
|
||||||
|
db.rollback()
|
||||||
|
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] 迁移执行记录 {moved_updates} 条 → custom_updates")
|
||||||
|
print(f"[ok] 合同金额合计 ¥{amount_sum:,.2f}")
|
||||||
|
from collections import Counter
|
||||||
|
print("[ok] 阶段分布:", dict(Counter(c.stage for c in by_name.values())))
|
||||||
|
print(f"[ok] 常规项目剩余 {db.query(Project).count()} 个(已移除「{CONTAINER}」)")
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -0,0 +1,110 @@
|
||||||
|
"""把全部周期的年份整体平移 N 年(默认 +1)。
|
||||||
|
|
||||||
|
场景:历史数据的年份口径需要调整(例如 2024→2025、2025→2026)。
|
||||||
|
|
||||||
|
注意实现要点:
|
||||||
|
- Period 同时存在 2024 与 2025,必须**按年份从大到小**更新,
|
||||||
|
否则 2024→2025 会撞上尚未更新的 2025,造成 sort_key 重复。
|
||||||
|
- Period.label 存的是『8月第4周』这类不含年份的原始文本,无需改动;
|
||||||
|
带年份的显示名(full_label)是按 year 动态生成的,会自动跟随。
|
||||||
|
- 执行前会校验 sort_key 是否出现重复,有重复则整体回滚。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python scripts/shift_year.py --dry-run # 预演
|
||||||
|
python scripts/shift_year.py # 全部 +1 年
|
||||||
|
python scripts/shift_year.py --years -1 # 反向:全部 -1 年
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
||||||
|
|
||||||
|
from app.db import SessionLocal # noqa: E402
|
||||||
|
from app.models import CustomProject, Period # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
def shift_date(value: str | None, years: int) -> str | None:
|
||||||
|
"""把 '2025-08-14' 这类日期字符串的年份平移。"""
|
||||||
|
if not value:
|
||||||
|
return value
|
||||||
|
m = re.match(r"^(\d{4})(.*)$", str(value).strip())
|
||||||
|
if not m:
|
||||||
|
return value
|
||||||
|
return f"{int(m.group(1)) + years}{m.group(2)}"
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser()
|
||||||
|
ap.add_argument("--years", type=int, default=1, help="平移年数,默认 +1")
|
||||||
|
ap.add_argument("--dry-run", action="store_true")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if args.years == 0:
|
||||||
|
print("平移年数为 0,无需处理")
|
||||||
|
return
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
# 按年份从大到小,避免中间态撞车
|
||||||
|
periods = db.query(Period).order_by(Period.year.desc(), Period.sort_key.desc()).all()
|
||||||
|
if not periods:
|
||||||
|
print("没有周期数据")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"周期共 {len(periods)} 个,年份平移 {args.years:+d} 年")
|
||||||
|
print(f"{'原周期':>22} → {'新周期':<22}")
|
||||||
|
print("-" * 56)
|
||||||
|
changed = 0
|
||||||
|
target_keys = []
|
||||||
|
for p in periods:
|
||||||
|
new_year = p.year + args.years
|
||||||
|
new_key = new_year * 10000 + p.month * 100 + p.week
|
||||||
|
target_keys.append(new_key)
|
||||||
|
old_label = f"{p.year}年{p.month}月第{p.week}周"
|
||||||
|
new_label = f"{new_year}年{p.month}月第{p.week}周"
|
||||||
|
if changed < 6 or changed >= len(periods) - 4:
|
||||||
|
print(f"{old_label:>22} → {new_label:<22}")
|
||||||
|
elif changed == 6:
|
||||||
|
print(f"{'…':>22} {'…'}")
|
||||||
|
p.year = new_year
|
||||||
|
p.sort_key = new_key
|
||||||
|
changed += 1
|
||||||
|
|
||||||
|
# 冲突校验
|
||||||
|
if len(set(target_keys)) != len(target_keys):
|
||||||
|
db.rollback()
|
||||||
|
print("!! 平移后出现重复周期,已回滚。请先清理重复的周期数据。")
|
||||||
|
return
|
||||||
|
|
||||||
|
# 定制项目的日期字段同步平移
|
||||||
|
date_changed = 0
|
||||||
|
for cp in db.query(CustomProject).all():
|
||||||
|
for field in ("sign_date", "deliver_date", "revenue_date"):
|
||||||
|
old = getattr(cp, field)
|
||||||
|
new = shift_date(old, args.years)
|
||||||
|
if new != old:
|
||||||
|
setattr(cp, field, new)
|
||||||
|
date_changed += 1
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
db.rollback()
|
||||||
|
print("-" * 56)
|
||||||
|
print(f"[dry-run] 将平移 {changed} 个周期、{date_changed} 个日期字段,未写入。")
|
||||||
|
return
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
print("-" * 56)
|
||||||
|
print(f"[ok] 已平移 {changed} 个周期({args.years:+d} 年),日期字段 {date_changed} 个")
|
||||||
|
latest = db.query(Period).order_by(Period.sort_key.desc()).first()
|
||||||
|
print(f"[ok] 当前最新周期:{latest.year}年{latest.month}月第{latest.week}周")
|
||||||
|
print(" 提示:如需回滚,执行 python scripts/shift_year.py --years " + f"{-args.years}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,210 @@
|
||||||
|
/* 图标库:统一 24×24 线性 SVG,描边走 currentColor,重点色由外层 CSS 决定。
|
||||||
|
用法:Icon.svg("wallet") → 裸图标
|
||||||
|
Icon.badge("wallet") → 图标 + 重点色底板
|
||||||
|
Icon.status("in_progress") → 执行状态图标
|
||||||
|
Icon.stage("signed") → 定制阶段图标
|
||||||
|
Icon.pipe("entry") → 交付环节图标
|
||||||
|
静态标记里写 <i data-icon="wallet"></i>,加载时自动替换成 SVG。 */
|
||||||
|
(function () {
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
var P = {
|
||||||
|
/* 导航 */
|
||||||
|
board: '<rect x="3" y="3" width="7.5" height="9" rx="1.6"/><rect x="13.5" y="3" width="7.5" height="5" rx="1.6"/><rect x="13.5" y="12" width="7.5" height="9" rx="1.6"/><rect x="3" y="16" width="7.5" height="5" rx="1.6"/>',
|
||||||
|
"list-checks": '<path d="M3.4 6.6l1.7 1.7L8.2 5.2"/><path d="M3.4 12.6l1.7 1.7 3.1-3.1"/><path d="M3.4 18.6l1.7 1.7 3.1-3.1"/><path d="M11.6 7h9.2M11.6 13h9.2M11.6 19h9.2"/>',
|
||||||
|
package: '<path d="M12 3.4l8.1 4.4v8.4L12 20.6l-8.1-4.4V7.8L12 3.4z"/><path d="M4.2 8L12 12.3 19.8 8"/><path d="M12 12.3v8.3"/>',
|
||||||
|
target: '<circle cx="12" cy="12" r="8.4"/><circle cx="12" cy="12" r="4.2"/><circle cx="12" cy="12" r="1.1"/>',
|
||||||
|
users: '<circle cx="9.4" cy="8" r="3.4"/><path d="M3 20a6.4 6.4 0 0112.8 0"/><path d="M16.4 5.4a3.4 3.4 0 010 5.2"/><path d="M18.4 14.7A6.4 6.4 0 0121 20"/>',
|
||||||
|
pencil: '<path d="M4 20.2l.9-4L16.2 4.9a2.1 2.1 0 013 3L7.9 19.3l-3.9.9z"/><path d="M14.8 6.3l2.9 2.9"/>',
|
||||||
|
sliders: '<path d="M5 7h8.8M18.6 7H21M5 17h3.4M12.8 17h5.2"/><circle cx="16" cy="7" r="2.2"/><circle cx="10.6" cy="17" r="2.2"/>',
|
||||||
|
/* 通用动作 */
|
||||||
|
plus: '<path d="M12 5.2v13.6M5.2 12h13.6"/>',
|
||||||
|
x: '<path d="M6.2 6.2l11.6 11.6M17.8 6.2L6.2 17.8"/>',
|
||||||
|
"chevron-left": '<path d="M14.8 5.4L8 12l6.8 6.6"/>',
|
||||||
|
"chevron-right": '<path d="M9.2 5.4L16 12l-6.8 6.6"/>',
|
||||||
|
"chevron-down": '<path d="M6 9.2l6 6 6-6"/>',
|
||||||
|
"arrow-right": '<path d="M4.2 12h14.4M13 6.4l5.6 5.6L13 17.6"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="6.4"/><path d="M15.8 15.8l4.4 4.4"/>',
|
||||||
|
download: '<path d="M12 4v10.4M8 10.8l4 4 4-4"/><path d="M4.4 19.4h15.2"/>',
|
||||||
|
check: '<path d="M5 12.6l4.6 4.6L19 7"/>',
|
||||||
|
"check-circle": '<circle cx="12" cy="12" r="8.4"/><path d="M8.4 12.3l2.5 2.5 4.9-5"/>',
|
||||||
|
circle: '<circle cx="12" cy="12" r="8.4"/>',
|
||||||
|
clock: '<circle cx="12" cy="12" r="8.4"/><path d="M12 7.4V12l3.2 2"/>',
|
||||||
|
alert: '<path d="M12 4.2l8.6 15.1H3.4L12 4.2z"/><path d="M12 9.8v4.2M12 16.9h.01"/>',
|
||||||
|
"alert-circle": '<circle cx="12" cy="12" r="8.4"/><path d="M12 7.8v4.6M12 16.2h.01"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="8.4"/><path d="M12 11v5.2M12 7.9h.01"/>',
|
||||||
|
ban: '<circle cx="12" cy="12" r="8.4"/><path d="M6.1 6.1l11.8 11.8"/>',
|
||||||
|
pause: '<path d="M9.4 5.6v12.8M14.6 5.6v12.8"/>',
|
||||||
|
refresh: '<path d="M19.6 12a7.6 7.6 0 11-2.3-5.4"/><path d="M20 4.2v4.4h-4.4"/>',
|
||||||
|
star: '<path d="M12 3.9l2.55 5.2 5.75.83-4.15 4.05.98 5.72L12 17.02l-5.13 2.68.98-5.72L3.7 9.93l5.75-.83L12 3.9z"/>',
|
||||||
|
filter: '<path d="M4 5.8h16l-6.2 7.3v6l-3.6-2.1v-3.9L4 5.8z"/>',
|
||||||
|
sparkles: '<path d="M10.8 4l1.5 3.6 3.6 1.5-3.6 1.5-1.5 3.6-1.5-3.6L5.7 9.1l3.6-1.5L10.8 4z"/><path d="M17.6 14.6l.8 1.9 1.9.8-1.9.8-.8 1.9-.8-1.9-1.9-.8 1.9-.8.8-1.9z"/>',
|
||||||
|
trash: '<path d="M4.4 7h15.2"/><path d="M9.4 7V5.6A1.6 1.6 0 0111 4h2a1.6 1.6 0 011.6 1.6V7"/><path d="M6.6 7l.9 12.4A1.6 1.6 0 009.1 21h5.8a1.6 1.6 0 001.6-1.6L17.4 7"/>',
|
||||||
|
archive: '<rect x="3.2" y="4.2" width="17.6" height="4.4" rx="1.5"/><path d="M5.2 8.6v10.2a1.6 1.6 0 001.6 1.6h10.4a1.6 1.6 0 001.6-1.6V8.6"/><path d="M10 12.4h4"/>',
|
||||||
|
flag: '<path d="M6 20.6V4.2h11.6l-2.1 4.2 2.1 4.2H6"/>',
|
||||||
|
/* 业务语义 */
|
||||||
|
calendar: '<rect x="3.4" y="5.2" width="17.2" height="15.2" rx="2"/><path d="M3.4 10h17.2M8 3.4v3.4M16 3.4v3.4"/>',
|
||||||
|
"calendar-check": '<rect x="3.4" y="5.2" width="17.2" height="15.2" rx="2"/><path d="M3.4 10h17.2M8 3.4v3.4M16 3.4v3.4"/><path d="M8.8 14.4l2.1 2.1 4.3-4.3"/>',
|
||||||
|
user: '<circle cx="12" cy="8" r="3.5"/><path d="M5 20.2a7 7 0 0114 0"/>',
|
||||||
|
layers: '<path d="M12 3.4l8.4 4.4-8.4 4.4L3.6 7.8 12 3.4z"/><path d="M4 12.4l8 4.2 8-4.2"/><path d="M4 16.6l8 4.2 8-4.2"/>',
|
||||||
|
activity: '<path d="M3 12.4h3.8l2.4-6.2 4 12.2 2.4-6h5"/>',
|
||||||
|
pie: '<circle cx="12" cy="12" r="8.4"/><path d="M12 3.6V12h8.4"/>',
|
||||||
|
"bar-chart": '<path d="M4.6 20.2V11M12 20.2V4.4M19.4 20.2v-6.4M2.6 20.2h18.8"/>',
|
||||||
|
wallet: '<rect x="3.2" y="6" width="17.6" height="12.6" rx="2.4"/><path d="M3.2 10.4h17.6"/><circle cx="16.4" cy="14.6" r="1.2"/>',
|
||||||
|
receipt: '<path d="M6.2 3.6h11.6v17l-2.9-1.7-2.9 1.7-2.9-1.7-2.9 1.7v-17z"/><path d="M9.2 8.4h5.6M9.2 12.2h5.6"/>',
|
||||||
|
"trending-up": '<path d="M3.6 16.6l5.9-5.9 3.6 3.6 6.9-7.3"/><path d="M16.6 6.6h4.2v4.2"/>',
|
||||||
|
inbox: '<path d="M4 13.8h4l1.4 2.4h5.2l1.4-2.4h4"/><path d="M6.4 4.3h11.2l2.9 9.5v4.3a1.8 1.8 0 01-1.8 1.8H5.3a1.8 1.8 0 01-1.8-1.8v-4.3L6.4 4.3z"/>',
|
||||||
|
"shield-check": '<path d="M12 3.4l7 2.6v5.3c0 4.1-2.9 7.3-7 8.6-4.1-1.3-7-4.5-7-8.6V6l7-2.6z"/><path d="M8.8 11.9l2.2 2.2 4.2-4.2"/>',
|
||||||
|
gauge: '<path d="M4 17.6a8.4 8.4 0 1116 0"/><path d="M12 17.6l4-4.8"/><path d="M4 17.6h16"/>',
|
||||||
|
hourglass: '<path d="M7 3.6h10M7 20.4h10"/><path d="M7.6 3.6V7L12 11.4 16.4 7V3.6"/><path d="M7.6 20.4V17L12 12.6 16.4 17v3.4"/>',
|
||||||
|
note: '<path d="M5 4.4h14v10.4L14.4 19.4H5V4.4z"/><path d="M19 14.8h-4.6v4.6"/><path d="M8.4 9h7M8.4 12.4h4"/>',
|
||||||
|
database: '<ellipse cx="12" cy="6" rx="7.4" ry="2.9"/><path d="M4.6 6v11.9c0 1.6 3.3 2.9 7.4 2.9s7.4-1.3 7.4-2.9V6"/><path d="M19.4 12c0 1.6-3.3 2.9-7.4 2.9S4.6 13.6 4.6 12"/>',
|
||||||
|
bell: '<path d="M6.6 10.4a5.4 5.4 0 0110.8 0c0 4.2 1.4 5.6 1.4 5.6H5.2s1.4-1.4 1.4-5.6z"/><path d="M10 19a2.1 2.1 0 004 0"/>',
|
||||||
|
code: '<path d="M8.8 7.4L4.4 12l4.4 4.6"/><path d="M15.2 7.4L19.6 12l-4.4 4.6"/>',
|
||||||
|
link: '<path d="M10.2 13.8l3.6-3.6"/><path d="M8.6 11l-2 2a3.4 3.4 0 004.8 4.8l2-2"/><path d="M15.4 13l2-2a3.4 3.4 0 00-4.8-4.8l-2 2"/>',
|
||||||
|
};
|
||||||
|
|
||||||
|
var STATUS_ICON = {
|
||||||
|
not_started: "circle",
|
||||||
|
in_progress: "refresh",
|
||||||
|
done: "check-circle",
|
||||||
|
at_risk: "alert",
|
||||||
|
blocked: "ban",
|
||||||
|
paused: "pause",
|
||||||
|
};
|
||||||
|
|
||||||
|
var STAGE_ICON = {
|
||||||
|
lead: "target",
|
||||||
|
signed: "pencil",
|
||||||
|
developing: "code",
|
||||||
|
delivered: "package",
|
||||||
|
accepted: "shield-check",
|
||||||
|
revenue: "wallet",
|
||||||
|
paused: "pause",
|
||||||
|
closed: "archive",
|
||||||
|
};
|
||||||
|
|
||||||
|
var PIPE_ICON = { entry: "inbox", signed: "pencil", delivered: "package", accepted: "shield-check", revenue: "wallet" };
|
||||||
|
|
||||||
|
function svg(name, cls, size) {
|
||||||
|
var body = P[name];
|
||||||
|
if (!body) return "";
|
||||||
|
var attrs =
|
||||||
|
'class="ic' + (cls ? " " + cls : "") + '" viewBox="0 0 24 24" fill="none" stroke="currentColor"' +
|
||||||
|
' stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
|
||||||
|
if (size) attrs += ' width="' + size + '" height="' + size + '"';
|
||||||
|
return "<svg " + attrs + ">" + body + "</svg>";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 图标 + 重点色小底板:重点色来自外层 --c */
|
||||||
|
function badge(name, cls) {
|
||||||
|
return '<span class="ic-badge' + (cls ? " " + cls : "") + '">' + svg(name) + "</span>";
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(code) { return svg(STATUS_ICON[code] || "circle"); }
|
||||||
|
function nameOfStatus(code) { return STATUS_ICON[code] || "circle"; }
|
||||||
|
function nameOfStage(code) { return STAGE_ICON[code] || "circle"; }
|
||||||
|
function statusBadge(code) { return badge(nameOfStatus(code)); }
|
||||||
|
function stage(code) { return svg(STAGE_ICON[code] || "circle"); }
|
||||||
|
function pipe(key) { return svg(PIPE_ICON[key] || "circle"); }
|
||||||
|
|
||||||
|
function hydrate(root) {
|
||||||
|
var nodes = (root || document).querySelectorAll("[data-icon]");
|
||||||
|
for (var i = 0; i < nodes.length; i++) {
|
||||||
|
var el = nodes[i];
|
||||||
|
el.innerHTML = svg(el.getAttribute("data-icon"), el.getAttribute("data-icon-class") || "");
|
||||||
|
el.removeAttribute("data-icon");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 按标题关键词自动配图标 ----------
|
||||||
|
标题类文案(卡片标题 / 区块标题 / 看板列头 / 左栏分组头)由渲染函数拼装,
|
||||||
|
逐个手写图标会散落几十处;这里按关键词统一命中一次,命中不到就不加图标。 */
|
||||||
|
var TITLE_ICON = [
|
||||||
|
["工作重点", "list-checks"], ["重点工作", "list-checks"], ["工作重点", "list-checks"],
|
||||||
|
["执行记录", "activity"], ["交付记录", "activity"], ["更新活跃度", "activity"], ["活跃度", "activity"],
|
||||||
|
["未更新", "alert-circle"], ["卡点", "alert-circle"], ["预警", "alert-circle"], ["风险", "alert"],
|
||||||
|
["状态分布", "gauge"], ["进度", "gauge"], ["趋势", "trending-up"], ["走势", "trending-up"],
|
||||||
|
["分类分布", "pie"], ["金额分布", "pie"], ["金额", "wallet"],
|
||||||
|
["财务", "wallet"], ["收入", "wallet"], ["计收", "wallet"], ["合同", "wallet"],
|
||||||
|
["开票", "receipt"], ["回款", "receipt"],
|
||||||
|
["负责人", "users"], ["人员", "users"], ["人力", "users"], ["花名册", "users"], ["在册", "users"],
|
||||||
|
["区域", "layers"], ["归属", "layers"], ["分类", "pie"],
|
||||||
|
["明细", "list-checks"], ["待办", "list-checks"], ["本周新增", "inbox"], ["有变化", "refresh"],
|
||||||
|
["在管项目", "layers"], ["定制项目", "package"], ["交付环节", "layers"], ["交付漏斗", "filter"],
|
||||||
|
["交付", "package"], ["验收", "shield-check"], ["入库", "inbox"], ["签单", "pencil"],
|
||||||
|
["阶段流转", "refresh"], ["阶段", "layers"], ["线索", "target"], ["开发", "code"],
|
||||||
|
["重点项目", "star"], ["重点", "star"], ["周期反馈", "calendar-check"], ["周期管理", "calendar"],
|
||||||
|
["周期", "calendar"], ["更新录入", "pencil"], ["最近更新", "clock"], ["整理结果", "sparkles"],
|
||||||
|
["配置", "sliders"], ["数据库", "database"], ["统计概览", "bar-chart"], ["概览", "bar-chart"],
|
||||||
|
["基础信息", "info"], ["基本信息", "info"], ["说明", "info"], ["反馈", "note"], ["记录", "note"],
|
||||||
|
["归档", "archive"], ["项目", "layers"],
|
||||||
|
];
|
||||||
|
var EMPTY_ICON = [["加载", "refresh"], ["没有", "inbox"], ["暂无", "inbox"], ["未填写", "note"], ["找不到", "search"]];
|
||||||
|
|
||||||
|
function pickIcon(text, rules) {
|
||||||
|
for (var i = 0; i < rules.length; i++) if (text.indexOf(rules[i][0]) >= 0) return rules[i][1];
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function leadingIcon(el, name, cls) {
|
||||||
|
if (!el || el.getAttribute("data-ico")) return;
|
||||||
|
el.setAttribute("data-ico", "1");
|
||||||
|
if (name) el.insertAdjacentHTML("afterbegin", svg(name, cls));
|
||||||
|
}
|
||||||
|
|
||||||
|
function text(el) {
|
||||||
|
return (el.textContent || "").replace(/\s+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 给一处渲染结果补图标:静态标记里的 data-icon + 标题/空态/提示条的关键词图标 */
|
||||||
|
function auto(root) {
|
||||||
|
if (!root) return;
|
||||||
|
if (root.nodeType === 1) fill(root);
|
||||||
|
if (!root.querySelectorAll) return;
|
||||||
|
var nodes = root.querySelectorAll("[data-icon], .card-head h4, .sec-title, .kcol-title, .wk-group-head, .empty, .notice");
|
||||||
|
fillAll(nodes);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillAll(nodes) {
|
||||||
|
for (var i = 0; i < nodes.length; i++) fill(nodes[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fill(el) {
|
||||||
|
if (el.getAttribute("data-icon")) {
|
||||||
|
el.innerHTML = svg(el.getAttribute("data-icon"), el.getAttribute("data-icon-class") || "");
|
||||||
|
el.removeAttribute("data-icon");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (el.matches(".card-head h4, .sec-title, .kcol-title, .wk-group-head")) {
|
||||||
|
// 模板里已经自带前导图标的标题(如阶段看板的阶段图标)不再自动补第二个
|
||||||
|
if (el.querySelector(":scope > .ic")) { el.setAttribute("data-ico", "1"); return; }
|
||||||
|
leadingIcon(el, pickIcon(text(el), TITLE_ICON), "ti");
|
||||||
|
} else if (el.matches(".notice")) {
|
||||||
|
leadingIcon(el, el.classList.contains("err") ? "alert-circle" : el.classList.contains("warn") ? "alert-circle" : "info", "ti");
|
||||||
|
} else if (el.matches(".empty")) {
|
||||||
|
leadingIcon(el, pickIcon(text(el), EMPTY_ICON) || "inbox", "ic-lg");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function observe() {
|
||||||
|
if (!window.MutationObserver) return;
|
||||||
|
new MutationObserver(function (ms) {
|
||||||
|
for (var i = 0; i < ms.length; i++) {
|
||||||
|
var added = ms[i].addedNodes;
|
||||||
|
for (var j = 0; j < added.length; j++) auto(added[j]);
|
||||||
|
}
|
||||||
|
}).observe(document.body, { childList: true, subtree: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
window.Icon = { svg: svg, badge: badge, status: status, stage: stage, pipe: pipe,
|
||||||
|
nameOfStatus: nameOfStatus, nameOfStage: nameOfStage, statusBadge: statusBadge,
|
||||||
|
hydrate: hydrate, auto: auto, names: P };
|
||||||
|
function boot() {
|
||||||
|
hydrate(document);
|
||||||
|
auto(document);
|
||||||
|
observe();
|
||||||
|
}
|
||||||
|
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", boot);
|
||||||
|
else boot();
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>软件开发部 · 关键任务追踪看板</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<aside class="sidebar">
|
||||||
|
<div class="brand">
|
||||||
|
<div class="brand-mark"><i data-icon="target"></i></div>
|
||||||
|
<div class="brand-text">
|
||||||
|
<strong>关键任务追踪看板</strong>
|
||||||
|
<span>软件开发部</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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="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>
|
||||||
|
<button class="nav-item" data-view="intake"><span class="ni" data-icon="pencil"></span>更新录入</button>
|
||||||
|
<button class="nav-item" data-view="settings"><span class="ni" data-icon="sliders"></span>设置</button>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="side-block">
|
||||||
|
<div class="side-title">分类筛选</div>
|
||||||
|
<div class="side-list" id="sideCategories"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-block">
|
||||||
|
<label class="switch">
|
||||||
|
<input type="checkbox" id="sideKeyOnly" />
|
||||||
|
<span>只看重点项目</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="side-foot" id="sideFoot">—</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="period-bar">
|
||||||
|
<div class="seg" id="granSeg">
|
||||||
|
<button class="seg-btn active" data-gran="week">周</button>
|
||||||
|
<button class="seg-btn" data-gran="month">月</button>
|
||||||
|
</div>
|
||||||
|
<button class="nav-arrow" id="prevPeriod" title="上一期"><i data-icon="chevron-left"></i></button>
|
||||||
|
<select id="periodSelect" class="period-select"></select>
|
||||||
|
<button class="nav-arrow" id="nextPeriod" title="下一期"><i data-icon="chevron-right"></i></button>
|
||||||
|
<span class="period-hint" id="periodHint"></span>
|
||||||
|
</div>
|
||||||
|
<div class="topbar-right">
|
||||||
|
<input id="globalSearch" class="search" type="search" placeholder="搜索项目 / 内容…" />
|
||||||
|
<button class="btn primary" id="btnIntake"><i data-icon="plus"></i>更新录入</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="views">
|
||||||
|
<section class="view" id="view-board"></section>
|
||||||
|
<section class="view hidden" id="view-projects"></section>
|
||||||
|
<section class="view hidden" id="view-custom"></section>
|
||||||
|
<section class="view hidden" id="view-focus"></section>
|
||||||
|
<section class="view hidden" id="view-staff"></section>
|
||||||
|
<section class="view hidden" id="view-intake"></section>
|
||||||
|
<section class="view hidden" id="view-settings"></section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="drawer-mask" id="drawerMask"></div>
|
||||||
|
<aside class="drawer" id="drawer">
|
||||||
|
<div class="drawer-head">
|
||||||
|
<div class="drawer-head-text">
|
||||||
|
<h3 id="drawerTitle">项目详情</h3>
|
||||||
|
<div class="drawer-sub" id="drawerSub"></div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-bar" id="drawerActions"></div>
|
||||||
|
<button class="icon-btn" id="drawerClose" title="关闭"><i data-icon="x"></i></button>
|
||||||
|
</div>
|
||||||
|
<div class="drawer-body" id="drawerBody"></div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="modal-mask" id="modalMask">
|
||||||
|
<div class="modal" id="modal"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script src="/static/icons.js"></script>
|
||||||
|
<script src="/static/app.js"></script>
|
||||||
|
<script src="/static/livereload.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
// 开发模式热加载:轮询 /health 的启动代际(boot),服务端因文件改动重启后自动刷新页面。
|
||||||
|
// 非 DEV 启动时第一次轮询就自行停止,生产环境零影响。
|
||||||
|
(function () {
|
||||||
|
var boot = null;
|
||||||
|
|
||||||
|
function poll() {
|
||||||
|
fetch("/health", { cache: "no-store" })
|
||||||
|
.then(function (r) { return r.json(); })
|
||||||
|
.then(function (info) {
|
||||||
|
if (!info || info.dev !== true) return; // 非开发模式:到此为止,不再轮询
|
||||||
|
if (boot === null) boot = info.boot;
|
||||||
|
else if (info.boot !== boot) { location.reload(); return; }
|
||||||
|
setTimeout(poll, 1000);
|
||||||
|
})
|
||||||
|
.catch(function () { setTimeout(poll, 1000); }); /* 服务正在重启,等下一轮 */
|
||||||
|
}
|
||||||
|
|
||||||
|
poll();
|
||||||
|
})();
|
||||||
|
|
@ -0,0 +1,554 @@
|
||||||
|
/* =============================================================
|
||||||
|
设计语言
|
||||||
|
· 不允许「彩色描边」:卡片/列表/步骤条一律中性灰边框或无边框
|
||||||
|
· 重点色只出现在三处:图标、关键数字/文本、小面积 tinted 底板
|
||||||
|
· 图标统一用 web/icons.js 的线性 SVG(stroke=currentColor)
|
||||||
|
============================================================= */
|
||||||
|
:root {
|
||||||
|
--bg: #f4f6f9;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--panel-2: #fafbfc;
|
||||||
|
--border: #e9ecf1;
|
||||||
|
--border-strong: #d4d9e0;
|
||||||
|
--text: #1f2430;
|
||||||
|
--text-2: #4a5261;
|
||||||
|
--muted: #7a8394;
|
||||||
|
--primary: #2563eb;
|
||||||
|
--primary-soft: #eef4ff;
|
||||||
|
--green: #16a34a;
|
||||||
|
--amber: #d97706;
|
||||||
|
--red: #dc2626;
|
||||||
|
--violet: #7c3aed;
|
||||||
|
--shadow: 0 1px 2px rgba(16,24,40,.04), 0 4px 14px rgba(16,24,40,.05);
|
||||||
|
--radius: 10px;
|
||||||
|
--radius-sm: 8px;
|
||||||
|
--sidebar-w: 232px;
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0; padding: 0; height: 100%;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Hiragino Sans GB",
|
||||||
|
"Microsoft YaHei", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
button, input, select, textarea { font-family: inherit; font-size: inherit; color: inherit; }
|
||||||
|
|
||||||
|
button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible, a:focus-visible {
|
||||||
|
outline: 2px solid var(--primary); outline-offset: 1px;
|
||||||
|
}
|
||||||
|
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||||
|
::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
::-webkit-scrollbar-thumb { background: #d7dbe2; border-radius: 99px; border: 3px solid transparent; background-clip: content-box; }
|
||||||
|
::-webkit-scrollbar-thumb:hover { background: #c2c8d2; background-clip: content-box; }
|
||||||
|
|
||||||
|
.app { display: flex; min-height: 100vh; }
|
||||||
|
|
||||||
|
/* ---------------- 图标基元 ---------------- */
|
||||||
|
.ic { width: 16px; height: 16px; flex: none; vertical-align: -3px; }
|
||||||
|
.ic-sm { width: 13px; height: 13px; }
|
||||||
|
.ic-lg { width: 22px; height: 22px; }
|
||||||
|
/* 标题前的小图标 */
|
||||||
|
.ti { width: 15px; height: 15px; flex: none; color: var(--muted); vertical-align: -2px; }
|
||||||
|
/* 重点色底板:--c 由外层注入(状态色 / 阶段色 / 重点色) */
|
||||||
|
.ic-badge {
|
||||||
|
display: inline-grid; place-items: center; flex: none;
|
||||||
|
width: 32px; height: 32px; border-radius: 9px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
background: color-mix(in srgb, var(--c, var(--primary)) 10%, #fff);
|
||||||
|
color: var(--c, var(--primary));
|
||||||
|
}
|
||||||
|
.ic-badge > .ic { width: 18px; height: 18px; }
|
||||||
|
.ic-badge.sm { width: 26px; height: 26px; border-radius: 8px; }
|
||||||
|
.ic-badge.sm > .ic { width: 15px; height: 15px; }
|
||||||
|
|
||||||
|
/* ---------------- sidebar ---------------- */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-w);
|
||||||
|
flex: 0 0 var(--sidebar-w);
|
||||||
|
background: var(--panel);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
position: sticky; top: 0; height: 100vh;
|
||||||
|
padding: 18px 14px;
|
||||||
|
}
|
||||||
|
.brand { display: flex; gap: 10px; align-items: center; margin-bottom: 22px; padding: 0 4px; }
|
||||||
|
.brand-mark {
|
||||||
|
width: 34px; height: 34px; border-radius: 10px; flex: none;
|
||||||
|
background: var(--primary); color: #fff;
|
||||||
|
display: grid; place-items: center;
|
||||||
|
}
|
||||||
|
.brand-mark .ic { width: 19px; height: 19px; }
|
||||||
|
.brand-text { display: flex; flex-direction: column; line-height: 1.3; }
|
||||||
|
.brand-text strong { font-size: 14px; }
|
||||||
|
.brand-text span { font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
.nav { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.nav-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 9px 10px; border: 0; border-radius: var(--radius-sm);
|
||||||
|
background: transparent; cursor: pointer; text-align: left;
|
||||||
|
color: var(--text-2); transition: .15s;
|
||||||
|
}
|
||||||
|
.nav-item:hover { background: var(--panel-2); color: var(--text); }
|
||||||
|
.nav-item.active { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
|
||||||
|
.ni { display: inline-flex; width: 16px; height: 16px; flex: none; color: var(--muted); }
|
||||||
|
.ni .ic { width: 16px; height: 16px; }
|
||||||
|
.nav-item.active .ni { color: var(--primary); }
|
||||||
|
|
||||||
|
.side-block { margin-top: 22px; }
|
||||||
|
.side-title { font-size: 11px; color: var(--muted); letter-spacing: .6px; margin-bottom: 8px; padding: 0 4px; }
|
||||||
|
.side-list { display: flex; flex-direction: column; gap: 2px; max-height: 260px; overflow: auto; }
|
||||||
|
.side-item {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 6px 8px; border-radius: var(--radius-sm); cursor: pointer; border: 0; background: transparent;
|
||||||
|
color: var(--text-2); width: 100%; text-align: left;
|
||||||
|
}
|
||||||
|
.side-item:hover { background: var(--panel-2); }
|
||||||
|
.side-item.active { background: var(--primary-soft); font-weight: 600; color: var(--text); }
|
||||||
|
.dot { width: 8px; height: 8px; border-radius: 3px; flex: 0 0 8px; }
|
||||||
|
.side-item .cnt { margin-left: auto; font-size: 11px; color: var(--muted); }
|
||||||
|
|
||||||
|
.switch { display: flex; align-items: center; gap: 8px; padding: 0 4px; cursor: pointer; color: var(--text-2); }
|
||||||
|
.side-foot { margin-top: auto; font-size: 11px; color: var(--muted); padding: 10px 4px; border-top: 1px solid var(--border); line-height: 1.6; }
|
||||||
|
|
||||||
|
/* ---------------- main ---------------- */
|
||||||
|
.main { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||||
|
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: 0; z-index: 20;
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 16px;
|
||||||
|
padding: 12px 22px;
|
||||||
|
background: rgba(255,255,255,.9);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.period-bar { display: flex; align-items: center; gap: 8px; }
|
||||||
|
.seg { display: flex; background: #eef0f4; border-radius: 9px; padding: 3px; }
|
||||||
|
.seg-btn {
|
||||||
|
border: 0; background: transparent; padding: 5px 14px; border-radius: 7px;
|
||||||
|
cursor: pointer; color: var(--text-2); font-size: 13px;
|
||||||
|
}
|
||||||
|
.seg-btn.active { background: #fff; color: var(--primary); font-weight: 600; box-shadow: 0 1px 2px rgba(0,0,0,.06); }
|
||||||
|
.nav-arrow {
|
||||||
|
display: inline-grid; place-items: center;
|
||||||
|
width: 30px; height: 30px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||||||
|
background: #fff; cursor: pointer; color: var(--text-2); padding: 0;
|
||||||
|
}
|
||||||
|
.nav-arrow .ic { width: 15px; height: 15px; }
|
||||||
|
.nav-arrow:hover { border-color: var(--border-strong); color: var(--text); }
|
||||||
|
.period-select {
|
||||||
|
height: 32px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
padding: 0 10px; background: #fff; min-width: 170px; cursor: pointer;
|
||||||
|
}
|
||||||
|
.period-hint { color: var(--muted); font-size: 12px; margin-left: 4px; }
|
||||||
|
.topbar-right { display: flex; gap: 10px; align-items: center; }
|
||||||
|
.search {
|
||||||
|
height: 32px; border: 1px solid var(--border); border-radius: var(--radius-sm);
|
||||||
|
padding: 0 12px; width: 220px; background: #fff;
|
||||||
|
}
|
||||||
|
.search:focus, .period-select:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
|
||||||
|
height: 32px; padding: 0 14px; border-radius: var(--radius-sm); border: 1px solid var(--border);
|
||||||
|
background: #fff; cursor: pointer; color: var(--text-2); white-space: nowrap;
|
||||||
|
}
|
||||||
|
.btn .ic { width: 14px; height: 14px; }
|
||||||
|
.btn:hover { border-color: var(--border-strong); color: var(--text); }
|
||||||
|
.btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; font-weight: 600; }
|
||||||
|
.btn.primary:hover { background: #1d4ed8; }
|
||||||
|
.btn.ghost { background: transparent; }
|
||||||
|
.btn.sm { height: 26px; padding: 0 10px; font-size: 12px; border-radius: 7px; }
|
||||||
|
.btn.sm .ic { width: 12px; height: 12px; }
|
||||||
|
.btn.danger { color: var(--red); }
|
||||||
|
.btn:disabled { opacity: .55; cursor: not-allowed; }
|
||||||
|
.icon-btn {
|
||||||
|
display: inline-grid; place-items: center; border: 0; background: transparent;
|
||||||
|
cursor: pointer; color: var(--muted); padding: 5px; border-radius: 7px;
|
||||||
|
}
|
||||||
|
.icon-btn .ic { width: 16px; height: 16px; }
|
||||||
|
.icon-btn:hover { background: var(--panel-2); color: var(--text); }
|
||||||
|
|
||||||
|
.views { padding: 20px 22px 60px; }
|
||||||
|
.hidden { display: none !important; }
|
||||||
|
|
||||||
|
/* ---------------- cards ---------------- */
|
||||||
|
.card {
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.card-head {
|
||||||
|
padding: 13px 16px; border-bottom: 1px solid var(--border);
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.card-head h4 { margin: 0; font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||||
|
.card-head h4 .t, .card-head .period-hint, .card-head .switch { white-space: nowrap; }
|
||||||
|
.card-head .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 8px; }
|
||||||
|
.card-body { padding: 16px; }
|
||||||
|
|
||||||
|
.grid { display: grid; gap: 16px; }
|
||||||
|
.grid.kpi {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
background: none; border: 0; box-shadow: none; padding: 0; border-radius: 0;
|
||||||
|
}
|
||||||
|
.grid.g2 { grid-template-columns: 1.35fr 1fr; }
|
||||||
|
.grid.g2e { grid-template-columns: 1fr 1fr; }
|
||||||
|
@media (max-width: 1180px) { .grid.g2, .grid.g2e { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* KPI:图标底板在左,数字用重点色 */
|
||||||
|
.kpi {
|
||||||
|
display: grid; grid-template-columns: auto 1fr; column-gap: 12px; align-items: center;
|
||||||
|
background: var(--panel); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius); padding: 14px 16px; box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
.kpi > .ic-badge { grid-row: 1 / span 3; }
|
||||||
|
.kpi > .k-label, .kpi > .k-value, .kpi > .k-foot { grid-column: 2; min-width: 0; }
|
||||||
|
.kpi .k-label { font-size: 12px; color: var(--muted); }
|
||||||
|
.kpi .k-value { font-size: 25px; font-weight: 700; margin-top: 4px; letter-spacing: -.5px; color: var(--c, var(--text)); }
|
||||||
|
.kpi .k-foot { font-size: 11px; color: var(--muted); margin-top: 5px; }
|
||||||
|
.kpi.accent-blue { --c: var(--primary); }
|
||||||
|
.kpi.accent-violet { --c: var(--violet); }
|
||||||
|
.kpi.accent-green { --c: var(--green); }
|
||||||
|
.kpi.accent-amber { --c: var(--amber); }
|
||||||
|
.kpi.accent-red { --c: var(--red); }
|
||||||
|
|
||||||
|
/* ---------------- board ---------------- */
|
||||||
|
.board-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||||
|
.chip {
|
||||||
|
display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 11px; border-radius: 999px; font-size: 12px;
|
||||||
|
background: var(--panel); border: 1px solid var(--border); color: var(--text-2); cursor: pointer;
|
||||||
|
}
|
||||||
|
.chip .ic { width: 13px; height: 13px; }
|
||||||
|
.chip.active { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
|
||||||
|
.chip.active .ic { color: var(--primary); }
|
||||||
|
|
||||||
|
.board-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
|
||||||
|
.pcard {
|
||||||
|
background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius);
|
||||||
|
padding: 14px 15px; cursor: pointer; box-shadow: var(--shadow);
|
||||||
|
transition: transform .12s, box-shadow .12s;
|
||||||
|
}
|
||||||
|
.pcard:hover { transform: translateY(-2px); box-shadow: 0 6px 20px rgba(16,24,40,.09); }
|
||||||
|
.pcard.idle { opacity: .62; }
|
||||||
|
.pcard-top { display: flex; align-items: flex-start; gap: 8px; }
|
||||||
|
.pcard-name { font-weight: 600; font-size: 14px; line-height: 1.35; flex: 1; }
|
||||||
|
.star { display: inline-flex; color: #d7dbe2; line-height: 1; user-select: none; cursor: pointer; flex: none; }
|
||||||
|
.star .ic { width: 14px; height: 14px; }
|
||||||
|
.star.on { color: #f0a500; }
|
||||||
|
.star.on .ic { fill: currentColor; }
|
||||||
|
.pcard-cat { font-size: 11px; color: var(--muted); margin-top: 3px; }
|
||||||
|
.pcard-summary {
|
||||||
|
margin-top: 10px; font-size: 12.5px; color: var(--text-2); line-height: 1.6;
|
||||||
|
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
|
||||||
|
min-height: 20px;
|
||||||
|
}
|
||||||
|
.pcard-meta { display: flex; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
|
||||||
|
.pill {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px;
|
||||||
|
font-size: 11px; padding: 2px 8px; border-radius: 999px; line-height: 1.6; font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.pill .ic { width: 12px; height: 12px; }
|
||||||
|
.tag-mini {
|
||||||
|
display: inline-flex; align-items: center; gap: 4px; white-space: nowrap;
|
||||||
|
font-size: 11px; padding: 2px 7px; border-radius: 6px; background: #f1f3f7; color: var(--text-2);
|
||||||
|
}
|
||||||
|
.tag-mini .ic { width: 12px; height: 12px; }
|
||||||
|
|
||||||
|
.progress { height: 6px; background: #eef0f4; border-radius: 99px; overflow: hidden; margin-top: 10px; }
|
||||||
|
.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 { 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);
|
||||||
|
border-radius: var(--radius); box-shadow: var(--shadow);
|
||||||
|
display: flex; flex-direction: column; max-height: 640px;
|
||||||
|
}
|
||||||
|
.kcol-head { padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||||
|
.kcol-title { display: flex; align-items: center; gap: 7px; font-weight: 600; font-size: 13px; }
|
||||||
|
.kcol-title .ic { color: var(--c, var(--muted)); }
|
||||||
|
.kcol-meta { font-size: 11px; color: var(--muted); margin-top: 3px; }
|
||||||
|
.kcol-body { padding: 10px; overflow: auto; display: flex; flex-direction: column; gap: 9px; flex: 1; }
|
||||||
|
.kcard { border: 1px solid var(--border); border-radius: 9px; padding: 10px 11px; background: #fff; cursor: pointer; transition: .12s; }
|
||||||
|
.kcard:hover { border-color: var(--border-strong); box-shadow: 0 4px 12px rgba(16,24,40,.08); }
|
||||||
|
.kcard-top { display: flex; gap: 6px; align-items: flex-start; }
|
||||||
|
.kcard-name { font-size: 12.5px; font-weight: 600; line-height: 1.45; flex: 1; }
|
||||||
|
.kcard-meta { display: flex; justify-content: space-between; align-items: center; margin-top: 7px; gap: 6px; font-size: 11.5px; color: var(--muted); }
|
||||||
|
.kcard-foot { display: flex; justify-content: space-between; font-size: 11px; color: var(--muted); margin-top: 7px; padding-top: 7px; border-top: 1px solid var(--border); }
|
||||||
|
.kcard-remark { font-size: 11px; color: var(--muted); margin-top: 6px; line-height: 1.5; }
|
||||||
|
|
||||||
|
.empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 40px 16px; text-align: center; color: var(--muted); font-size: 13px; }
|
||||||
|
.empty .ic-lg { color: #c9d0da; }
|
||||||
|
|
||||||
|
/* ---------------- tables ---------------- */
|
||||||
|
table.tbl { width: 100%; border-collapse: separate; border-spacing: 0; } /* separate:让操作列的 sticky 生效(collapse 下部分内核不吸边) */
|
||||||
|
table.tbl th, table.tbl td {
|
||||||
|
padding: 9px 12px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: middle;
|
||||||
|
}
|
||||||
|
table.tbl th { font-size: 12px; color: var(--muted); font-weight: 500; background: var(--panel-2); position: sticky; top: 0; white-space: nowrap; }
|
||||||
|
table.tbl tbody tr:hover { background: #fafbfd; }
|
||||||
|
table.tbl td.num, table.tbl th.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||||
|
.tbl-wrap { max-height: 620px; overflow: auto; }
|
||||||
|
.nowrap { white-space: nowrap; }
|
||||||
|
.inp, .sel {
|
||||||
|
height: 28px; border: 1px solid var(--border); border-radius: 7px; padding: 0 8px; background: #fff; max-width: 100%;
|
||||||
|
}
|
||||||
|
textarea.inp { height: auto; padding: 8px 10px; line-height: 1.6; }
|
||||||
|
.inp:focus, .sel:focus { outline: 2px solid var(--primary-soft); border-color: var(--primary); }
|
||||||
|
|
||||||
|
/* ---------------- drawer ---------------- */
|
||||||
|
.drawer-mask { position: fixed; inset: 0; background: rgba(18,24,38,.32); opacity: 0; pointer-events: none; transition: .2s; z-index: 40; }
|
||||||
|
.drawer-mask.open { opacity: 1; pointer-events: auto; }
|
||||||
|
.drawer {
|
||||||
|
position: fixed; top: 0; right: 0; bottom: 0; width: 620px; max-width: 92vw;
|
||||||
|
background: var(--panel); z-index: 50; transform: translateX(100%);
|
||||||
|
transition: transform .25s cubic-bezier(.4,0,.2,1);
|
||||||
|
display: flex; flex-direction: column; box-shadow: -8px 0 30px rgba(16,24,40,.12);
|
||||||
|
}
|
||||||
|
.drawer.open { transform: translateX(0); }
|
||||||
|
.drawer-head { padding: 14px 20px; border-bottom: 1px solid var(--border); display: flex; justify-content: space-between; align-items: center; gap: 12px; }
|
||||||
|
/* 标准对话框标题栏:标题在左,操作按钮成排在右,关闭图标在最右 */
|
||||||
|
.drawer-head-text { flex: 1; min-width: 0; }
|
||||||
|
.drawer-head h3 { margin: 0; font-size: 16px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; display: flex; align-items: center; gap: 8px; }
|
||||||
|
/* 多负责人 / 团队成员编辑器:人名下拉横向排开、自动换行,名单不分主次 */
|
||||||
|
.form-row.top { align-items: start; }
|
||||||
|
.people-rows { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; }
|
||||||
|
.people-row { display: inline-flex; align-items: center; gap: 2px; }
|
||||||
|
.people-del { width: 26px; padding: 0; justify-content: center; color: var(--muted); }
|
||||||
|
.people-del:hover { color: var(--red); }
|
||||||
|
/* 列表里的负责人列:负责人对等排成一排,不分主次 */
|
||||||
|
.people-cell { display: flex; flex-wrap: nowrap; gap: 4px; }
|
||||||
|
|
||||||
|
.btn-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
|
||||||
|
.drawer-sub { font-size: 12px; color: var(--muted); margin-top: 4px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.drawer-body { padding: 18px 20px 40px; overflow: auto; flex: 1; }
|
||||||
|
|
||||||
|
.sec { margin-bottom: 22px; }
|
||||||
|
.sec-title { font-size: 12px; color: var(--muted); letter-spacing: .5px; margin-bottom: 10px; display: flex; align-items: center; gap: 7px; }
|
||||||
|
.sec-title::after { content: ""; flex: 1; height: 1px; background: var(--border); }
|
||||||
|
.sec-title .sub { letter-spacing: 0; }
|
||||||
|
|
||||||
|
.form-row { display: grid; grid-template-columns: 88px 1fr; gap: 10px; align-items: center; margin-bottom: 10px; }
|
||||||
|
.form-row > label { font-size: 12px; color: var(--muted); }
|
||||||
|
/* 网格子项默认 min-width:auto,交付环节这类步骤条会把抽屉整行撑出横向滚动;
|
||||||
|
归零后由步骤条自己的 overflow-x 接管,表单标签保持不动 */
|
||||||
|
.form-row > div { min-width: 0; }
|
||||||
|
.inline-fields { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* 详情(只读)/ 修改(编辑)分离:只读值、区块操作条、区块属性徽标 */
|
||||||
|
.form-row > .ro { font-size: 13px; color: var(--text); line-height: 1.7; }
|
||||||
|
.sec-actions {
|
||||||
|
display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
|
||||||
|
margin-top: 14px; padding: 9px 11px;
|
||||||
|
background: var(--panel-2); border-radius: 9px;
|
||||||
|
}
|
||||||
|
.sec-actions-label { font-size: 11.5px; color: var(--muted); }
|
||||||
|
/* 附表的更新记录写入条:底色淡染,不再用虚线彩框 */
|
||||||
|
.sec-actions.upd { background: color-mix(in srgb, var(--green) 6%, #fff); }
|
||||||
|
.sec-actions.upd .sec-actions-label { color: var(--green); font-weight: 600; }
|
||||||
|
.sec-hint { font-size: 11.5px; color: var(--muted); margin-left: auto; }
|
||||||
|
.sec-kind {
|
||||||
|
font-size: 10.5px; font-weight: 600; letter-spacing: 0; padding: 1px 6px;
|
||||||
|
border-radius: 5px; background: var(--primary-soft); color: var(--primary);
|
||||||
|
}
|
||||||
|
.sec-kind.ro { background: #f1f3f7; color: var(--muted); font-weight: 500; }
|
||||||
|
.wk-i-name .star, .wk-head-title .star, .b-name .star, .drawer-head h3 .star { margin-right: 4px; vertical-align: -2px; }
|
||||||
|
.star.readonly { cursor: default; }
|
||||||
|
.star.readonly:not(.on) { opacity: .35; }
|
||||||
|
|
||||||
|
.upd-item { border-radius: 9px; padding: 11px 13px; margin-bottom: 10px; background: var(--panel-2); }
|
||||||
|
.upd-item .u-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
|
||||||
|
.upd-item .u-task { font-weight: 600; font-size: 13px; }
|
||||||
|
.upd-item .u-body { font-size: 12.5px; color: var(--text-2); line-height: 1.7; white-space: pre-wrap; word-break: break-word; }
|
||||||
|
.upd-item .u-risk { display: flex; align-items: flex-start; gap: 6px; margin-top: 8px; font-size: 12px; color: #92610c; background: #fdf6e9; border-radius: 7px; padding: 6px 9px; }
|
||||||
|
.upd-item .u-risk .ic { margin-top: 2px; color: var(--amber); }
|
||||||
|
.upd-item .u-next { display: flex; align-items: flex-start; gap: 6px; margin-top: 6px; font-size: 12px; color: var(--primary); background: var(--primary-soft); border-radius: 7px; padding: 6px 9px; }
|
||||||
|
.upd-item .u-next .ic { margin-top: 2px; }
|
||||||
|
|
||||||
|
.feed-bar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
|
||||||
|
.feed-bar .seg-btn { padding: 4px 10px; font-size: 12px; }
|
||||||
|
.feed-chips { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.feed-bar .chip { padding: 3px 9px; }
|
||||||
|
.feed-pager { display: flex; gap: 4px; }
|
||||||
|
.nav-arrow.sm { width: 24px; height: 24px; border-radius: 7px; }
|
||||||
|
.nav-arrow.sm .ic { width: 13px; height: 13px; }
|
||||||
|
.nav-arrow.sm:disabled { opacity: .45; cursor: not-allowed; }
|
||||||
|
.feed-meta { font-size: 11.5px; color: var(--muted); }
|
||||||
|
.feed-hd { display: flex; align-items: center; gap: 10px; font-size: 12.5px; color: var(--text-2); margin: 14px 0 8px; padding-bottom: 5px; border-bottom: 1px solid var(--border); }
|
||||||
|
.feed-hd b { font-size: 13px; color: var(--text); }
|
||||||
|
.feed-hd .feed-cnt { margin-left: auto; font-size: 11.5px; color: var(--muted); }
|
||||||
|
.feed-sub { font-size: 12px; color: var(--muted); margin: 8px 0 6px; }
|
||||||
|
|
||||||
|
.timeline-row { display: flex; align-items: center; gap: 10px; padding: 7px 0; border-bottom: 1px solid var(--border); }
|
||||||
|
.timeline-row .t-label { width: 110px; font-size: 12px; color: var(--text-2); flex: 0 0 110px; }
|
||||||
|
.timeline-row .t-bar { flex: 1; }
|
||||||
|
.timeline-row .t-val { width: 46px; text-align: right; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
.bullet-list { margin: 0; padding-left: 18px; font-size: 12.5px; color: var(--text-2); line-height: 1.8; }
|
||||||
|
.risk-item { color: var(--amber); }
|
||||||
|
|
||||||
|
.bar-row { display: flex; align-items: center; gap: 10px; margin-bottom: 9px; }
|
||||||
|
.bar-row .b-name { width: 150px; font-size: 12.5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.bar-row .b-track { flex: 1; height: 8px; background: #eef0f4; border-radius: 99px; overflow: hidden; }
|
||||||
|
.bar-row .b-track > i { display: block; height: 100%; border-radius: 99px; }
|
||||||
|
.bar-row .b-val { width: 44px; text-align: right; font-size: 12px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* ---------------- intake ---------------- */
|
||||||
|
.step { display: flex; gap: 10px; align-items: center; margin-bottom: 6px; }
|
||||||
|
.step-no { width: 22px; height: 22px; border-radius: 50%; background: var(--primary-soft); color: var(--primary); display: grid; place-items: center; font-size: 12px; font-weight: 700; flex: 0 0 22px; }
|
||||||
|
.step-title { font-weight: 600; font-size: 13px; }
|
||||||
|
.big-textarea { width: 100%; min-height: 190px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12.5px; }
|
||||||
|
.preview-box { border: 1px solid var(--border); border-radius: 9px; padding: 14px; background: var(--panel-2); }
|
||||||
|
.kv { display: grid; grid-template-columns: 92px 1fr; gap: 8px; align-items: center; margin-bottom: 8px; }
|
||||||
|
.kv > label { font-size: 12px; color: var(--muted); }
|
||||||
|
.notice { display: flex; align-items: flex-start; gap: 7px; font-size: 12px; padding: 9px 11px; border-radius: 9px; margin-bottom: 12px; line-height: 1.6; }
|
||||||
|
.notice .ti { width: 14px; height: 14px; margin-top: 2px; color: currentColor; }
|
||||||
|
.notice.warn { background: #fdf6e9; color: #92610c; }
|
||||||
|
.notice.info { background: var(--primary-soft); color: #1e40af; }
|
||||||
|
.notice.err { background: #fdf0f0; color: #b42318; }
|
||||||
|
|
||||||
|
/* ---------------- toast ---------------- */
|
||||||
|
.toast {
|
||||||
|
position: fixed; left: 50%; bottom: 34px; transform: translate(-50%, 20px);
|
||||||
|
background: #1f2430; color: #fff; padding: 10px 18px; border-radius: 9px;
|
||||||
|
font-size: 13px; opacity: 0; pointer-events: none; transition: .22s; z-index: 90;
|
||||||
|
}
|
||||||
|
.toast.show { opacity: 1; transform: translate(-50%, 0); }
|
||||||
|
|
||||||
|
.spinner { display: inline-block; width: 13px; height: 13px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: -2px; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.svg-chart { width: 100%; height: auto; display: block; }
|
||||||
|
.legend { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; font-size: 12px; color: var(--text-2); }
|
||||||
|
.legend i { width: 9px; height: 9px; border-radius: 3px; display: inline-block; margin-right: 5px; }
|
||||||
|
|
||||||
|
/* ---------------- modal ---------------- */
|
||||||
|
.modal-mask {
|
||||||
|
position: fixed; inset: 0; background: rgba(18,24,38,.38);
|
||||||
|
display: none; align-items: center; justify-content: center; padding: 24px; z-index: 60;
|
||||||
|
}
|
||||||
|
.modal-mask.open { display: flex; }
|
||||||
|
.modal {
|
||||||
|
width: 580px; max-width: 100%; max-height: 88vh; overflow: auto;
|
||||||
|
background: var(--panel); border-radius: var(--radius);
|
||||||
|
box-shadow: 0 20px 60px rgba(16,24,40,.22); animation: pop .16s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes pop { from { transform: translateY(8px) scale(.985); opacity: .5; } }
|
||||||
|
.modal-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 13px 18px; border-bottom: 1px solid var(--border); }
|
||||||
|
.modal-head h3 { margin: 0; font-size: 15px; }
|
||||||
|
.modal-body { padding: 16px 18px 6px; }
|
||||||
|
.modal-foot {
|
||||||
|
display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px;
|
||||||
|
border-top: 1px solid var(--border); margin-top: 14px;
|
||||||
|
position: sticky; bottom: 0; background: var(--panel);
|
||||||
|
}
|
||||||
|
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; }
|
||||||
|
.form-grid .full { grid-column: 1 / -1; }
|
||||||
|
.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);
|
||||||
|
}
|
||||||
|
table.tbl th.col-ops { background: var(--panel-2); z-index: 2; }
|
||||||
|
table.tbl tbody tr:hover td.col-ops { background: #fafbfd; }
|
||||||
|
table.tbl td.col-ops .btn.sm { padding: 0 7px; } /* 操作列按钮收紧,避免把整张表撑出横向滚动 */
|
||||||
|
|
||||||
|
/* ---------------- 周看板:左栏总览 + 右栏详情 ---------------- */
|
||||||
|
.wk-split { display: grid; grid-template-columns: 320px 1fr; gap: 16px; align-items: start; }
|
||||||
|
@media (max-width: 1180px) { .wk-split { grid-template-columns: 1fr; } }
|
||||||
|
.wk-left { padding: 10px; max-height: calc(100vh - 190px); overflow: auto; position: sticky; top: 84px; }
|
||||||
|
@media (max-width: 1180px) { .wk-left { position: static; max-height: none; } }
|
||||||
|
.wk-right { padding: 16px 18px; min-width: 0; }
|
||||||
|
|
||||||
|
.wk-group { margin-bottom: 6px; }
|
||||||
|
.wk-group-head {
|
||||||
|
display: flex; align-items: center; gap: 6px;
|
||||||
|
font-size: 12px; color: var(--muted); letter-spacing: .4px;
|
||||||
|
padding: 8px 6px 6px; position: sticky; top: 0; background: var(--panel); z-index: 1;
|
||||||
|
}
|
||||||
|
.wk-group-head .ic { width: 14px; height: 14px; }
|
||||||
|
.wk-group-head .cnt { font-size: 11px; background: #f1f3f7; color: var(--text-2); border-radius: 999px; padding: 0 6px; }
|
||||||
|
.wk-group-foot { margin-left: auto; font-size: 11px; }
|
||||||
|
|
||||||
|
/* 列表行:无边框,选中时用状态色淡染 */
|
||||||
|
.wk-item {
|
||||||
|
border-radius: 9px; padding: 9px 10px; margin-bottom: 3px;
|
||||||
|
cursor: pointer; background: transparent; transition: .12s;
|
||||||
|
}
|
||||||
|
.wk-item:hover { background: var(--panel-2); }
|
||||||
|
.wk-item.active {
|
||||||
|
background: var(--primary-soft);
|
||||||
|
background: color-mix(in srgb, var(--c, var(--primary)) 10%, #fff);
|
||||||
|
}
|
||||||
|
.wk-item.active .wk-i-name { color: var(--c, var(--text)); }
|
||||||
|
.wk-i-top { display: flex; align-items: center; gap: 7px; }
|
||||||
|
.wk-i-ic { display: inline-flex; color: var(--c, var(--muted)); flex: none; }
|
||||||
|
.wk-i-ic .ic { width: 15px; height: 15px; }
|
||||||
|
.wk-i-name { font-size: 13px; font-weight: 600; line-height: 1.4; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.wk-i-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; margin-top: 7px; padding-left: 22px; }
|
||||||
|
.wk-i-sub { margin-top: 6px; font-size: 11px; color: var(--muted); line-height: 1.5; padding-left: 22px; }
|
||||||
|
|
||||||
|
.wk-head { margin-bottom: 20px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
|
||||||
|
.wk-head-title { display: flex; align-items: center; gap: 9px; font-size: 16px; font-weight: 600; line-height: 1.4; }
|
||||||
|
.wk-head-title .ic-badge { width: 28px; height: 28px; border-radius: 8px; }
|
||||||
|
.wk-head-title .ic-badge > .ic { width: 16px; height: 16px; }
|
||||||
|
.wk-head .wk-i-meta, .wk-head .wk-i-sub { padding-left: 0; }
|
||||||
|
|
||||||
|
/* 交付环节步骤条:入库 → 签单 → 交付 → 验收 → 计收 */
|
||||||
|
.pipe { display: flex; align-items: flex-start; gap: 6px; overflow-x: auto; padding-bottom: 4px; }
|
||||||
|
.pipe-step {
|
||||||
|
/* 132px = 日期框(含选择器图标)的完整宽度;放不下交给 .pipe 横向滚动 */
|
||||||
|
flex: 1 1 132px; min-width: 132px; border: 1px solid var(--border); border-radius: 9px;
|
||||||
|
padding: 8px 9px; background: var(--panel-2);
|
||||||
|
}
|
||||||
|
.pipe-step.done { background: #fff; }
|
||||||
|
.pipe-step.now { background: color-mix(in srgb, var(--c, var(--primary)) 10%, #fff); }
|
||||||
|
.pipe-name { display: flex; align-items: center; gap: 5px; font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; }
|
||||||
|
.pipe-step.done .pipe-name, .pipe-step.now .pipe-name { color: var(--c, var(--text-2)); }
|
||||||
|
.pipe-name .ic { width: 13px; height: 13px; color: var(--c, var(--muted)); }
|
||||||
|
.pipe-step .inp { width: 100%; min-width: 0; height: 26px; font-size: 12px; }
|
||||||
|
.pipe-date { font-size: 12px; color: var(--text-2); min-width: 80px; }
|
||||||
|
.pipe-date.none { color: var(--muted); opacity: .55; }
|
||||||
|
.pipe-arrow { align-self: center; color: var(--muted); padding-top: 12px; flex: none; }
|
||||||
|
.pipe-arrow .ic { width: 14px; height: 14px; }
|
||||||
|
|
||||||
|
/* 交付漏斗 */
|
||||||
|
.funnel { display: grid; grid-template-columns: repeat(auto-fit, minmax(124px, 1fr)); gap: 12px; }
|
||||||
|
.fun-step { border: 1px solid var(--border); border-radius: 9px; padding: 11px 12px; background: var(--panel-2); }
|
||||||
|
.fun-name { display: flex; align-items: center; gap: 5px; font-size: 12px; color: var(--text-2); }
|
||||||
|
.fun-name .ic { width: 13px; height: 13px; color: var(--c, var(--muted)); }
|
||||||
|
.fun-count { font-size: 22px; font-weight: 700; letter-spacing: -.5px; margin: 3px 0 8px; color: var(--c, var(--text)); }
|
||||||
|
.fun-bar { height: 6px; background: #eef0f4; border-radius: 99px; overflow: hidden; }
|
||||||
|
.fun-bar > i { display: block; height: 100%; border-radius: 99px; }
|
||||||
|
.fun-amt { font-size: 11px; color: var(--muted); margin-top: 6px; font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
|
/* 小字段:金额补录等内联输入 */
|
||||||
|
.field-mini { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* 主导航分组标题(业务模块 / 支撑功能) */
|
||||||
|
.nav-label { font-size: 11px; color: var(--muted); letter-spacing: .6px; padding: 14px 10px 4px; }
|
||||||
|
|
||||||
|
/* 工作看板底部的统计概览折叠区 */
|
||||||
|
details.card.ov { padding: 12px 16px; }
|
||||||
|
details.card.ov > summary {
|
||||||
|
cursor: pointer; font-size: 13px; font-weight: 600; color: var(--text-2); list-style: none;
|
||||||
|
display: flex; align-items: center; gap: 7px;
|
||||||
|
}
|
||||||
|
details.card.ov > summary::-webkit-details-marker { display: none; }
|
||||||
|
details.card.ov > summary .ov-chev { display: inline-flex; color: var(--muted); transition: transform .15s; }
|
||||||
|
details.card.ov > summary .ov-chev .ic { width: 14px; height: 14px; }
|
||||||
|
details.card.ov[open] > summary .ov-chev { transform: rotate(90deg); }
|
||||||
|
details.card.ov > summary .sub { font-size: 12px; color: var(--muted); font-weight: 400; margin-left: 4px; }
|
||||||
|
details.card.ov > .ov-body { padding-top: 16px; }
|
||||||
Loading…
Reference in New Issue