diff --git a/docs/superpowers/plans/2026-08-17-reimburse-detail-occur-date.md b/docs/superpowers/plans/2026-08-17-reimburse-detail-occur-date.md new file mode 100644 index 000000000..dbf31597f --- /dev/null +++ b/docs/superpowers/plans/2026-08-17-reimburse-detail-occur-date.md @@ -0,0 +1,552 @@ +# 报销明细发生时间(occur_date)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在报销明细上增加可选「发生时间」字段(仅年月日),贯通 backend / web-admin / bx-mobile 的录入与只读展示。 + +**Architecture:** 在 `bx_reimburse_detail` 新增可空 `DATE` 列 `occur_date`;创建/更新明细时透传写入;API 出参驼峰 `occurDate`。前端录入用日期选择器(可清空),只读场景始终展示该字段,空值显示 `—`。 + +**Tech Stack:** MySQL 5.7、FastAPI + SQLAlchemy + Pydantic v2、Vue 3 + Element Plus(web-admin)、Vue 3 + Vant(bx-mobile) + +**Spec:** `docs/superpowers/specs/2026-08-17-reimburse-detail-occur-date-design.md` + +--- + +## 文件结构 + +| 操作 | 文件 | 职责 | +|------|------|------| +| Create | `backend/scripts/migrate_20260817_bx_reimburse_detail_occur_date.sql` | DDL 加列 | +| Modify | `docs/sql/shanxia2.0.sql` | 快照表结构同步 | +| Modify | `backend/app/models/bx_models.py` | `BxReimburseDetail.occur_date` | +| Modify | `backend/app/utils/bx_codegen.py` | 新增 `parse_optional_date`(空→None;勿复用 `parse_reimburse_date`,因其空值会变成今天) | +| Modify | `backend/app/schemas/bx/reimburse.py` | Create/Out 增加 `occur_date` | +| Modify | `backend/app/api/bx/reimburse.py` | `_build_details_data` 透传并解析 | +| Modify | `backend/app/services/bx/reimburse.py` | create/update 明细写入 `occur_date` | +| Create | `backend/tests/test_bx_optional_date.py` | `parse_optional_date` 单测 | +| Modify | `web-admin/src/views/bx/reimburse/apply/index.vue` | 录入列 + 查看弹窗列 | +| Modify | `web-admin/src/views/bx/reimburse/approve/index.vue` | 只读明细列 | +| Modify | `web-admin/src/views/bx/view/mine/index.vue` | 只读明细列 | +| Modify | `web-admin/src/views/bx/view/todo/index.vue` | 只读明细列 | +| Modify | `web-admin/src/views/bx/view/all/index.vue` | 只读明细列 | +| Modify | `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue` | 录入字段 | +| Modify | `bx-mobile/src/views/reimburse/ReimburseDetailPage.vue` | 只读字段 | +| Modify | `bx-mobile/src/views/reimburse/ReimburseApprovePage.vue` | 只读字段 | + +**不变:** 列表筛选、导出、`ApprovalDetailPage`、主表字段、必填校验、未来日期限制。 + +**注意:** `viewDetailSummary` 用列索引 `2` 汇总金额;新增列在报销事由之后,金额仍是 index `2`,无需改汇总逻辑。 + +--- + +### Task 1: DDL + Model + +**Files:** +- Create: `backend/scripts/migrate_20260817_bx_reimburse_detail_occur_date.sql` +- Modify: `docs/sql/shanxia2.0.sql`(`CREATE TABLE bx_reimburse_detail` 段) +- Modify: `backend/app/models/bx_models.py`(`BxReimburseDetail`) + +- [ ] **Step 1: 写迁移脚本** + +```sql +-- backend/scripts/migrate_20260817_bx_reimburse_detail_occur_date.sql +-- 报销明细增加可选发生日期 occur_date + +ALTER TABLE `bx_reimburse_detail` + ADD COLUMN `occur_date` date DEFAULT NULL COMMENT '费用发生日期' + AFTER `amount`; +``` + +- [ ] **Step 2: 更新 SQL 快照** + +在 `docs/sql/shanxia2.0.sql` 的 `CREATE TABLE \`bx_reimburse_detail\`` 中,于 `` `amount` decimal(14,2) NOT NULL COMMENT '报销金额', `` 之后插入: + +```sql + `occur_date` date DEFAULT NULL COMMENT '费用发生日期', +``` + +- [ ] **Step 3: Model 加字段** + +在 `BxReimburseDetail` 中 `amount` 与 `tenant_no` 之间增加: + +```python + occur_date = Column(Date, nullable=True, comment="费用发生日期") +``` + +(`Date` 已在 `bx_models.py` 顶部从 sqlalchemy 导入。) + +- [ ] **Step 4: 在目标库执行迁移** + +```bash +# 按本地习惯执行,例如: +mysql -u... -p... your_db < backend/scripts/migrate_20260817_bx_reimburse_detail_occur_date.sql +``` + +Expected: 成功;`SHOW COLUMNS FROM bx_reimburse_detail LIKE 'occur_date';` 可见该列。 + +- [ ] **Step 5: Commit** + +```bash +git add backend/scripts/migrate_20260817_bx_reimburse_detail_occur_date.sql \ + docs/sql/shanxia2.0.sql \ + backend/app/models/bx_models.py +git commit -m "$(cat <<'EOF' +feat(bx): add occur_date column on reimburse detail + +Store optional expense occurrence date (DATE, nullable) for each +reimburse line item. +EOF +)" +``` + +--- + +### Task 2: parse_optional_date + 单测(TDD) + +**Files:** +- Create: `backend/tests/test_bx_optional_date.py` +- Modify: `backend/app/utils/bx_codegen.py` + +- [ ] **Step 1: 写失败单测** + +```python +# backend/tests/test_bx_optional_date.py +from datetime import date, datetime + +import pytest + +from app.utils.bx_codegen import parse_optional_date + + +def test_parse_optional_date_none_and_blank(): + assert parse_optional_date(None) is None + assert parse_optional_date("") is None + assert parse_optional_date(" ") is None + + +def test_parse_optional_date_string_and_date(): + assert parse_optional_date("2026-08-17") == date(2026, 8, 17) + assert parse_optional_date(date(2026, 1, 2)) == date(2026, 1, 2) + assert parse_optional_date(datetime(2026, 3, 4, 12, 0, 0)) == date(2026, 3, 4) + + +def test_parse_optional_date_invalid(): + with pytest.raises(ValueError): + parse_optional_date("2026/08/17") +``` + +- [ ] **Step 2: 跑测确认失败** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_bx_optional_date.py -v +``` + +Expected: FAIL(`parse_optional_date` 未定义) + +- [ ] **Step 3: 实现 `parse_optional_date`** + +在 `backend/app/utils/bx_codegen.py` 的 `parse_reimburse_date` 附近新增(**不要**改 `parse_reimburse_date` 行为): + +```python +def parse_optional_date(value: str | date | datetime | None) -> date | None: + """可选日期:空 → None;合法 YYYY-MM-DD / date / datetime → date。""" + if value is None: + return None + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + text_value = str(value).strip() + if not text_value: + return None + return datetime.strptime(text_value, "%Y-%m-%d").date() +``` + +- [ ] **Step 4: 跑测确认通过** + +```bash +cd backend && source venv/bin/activate && pytest tests/test_bx_optional_date.py -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/utils/bx_codegen.py backend/tests/test_bx_optional_date.py +git commit -m "$(cat <<'EOF' +feat(bx): add parse_optional_date for nullable occur dates + +Empty values map to None so optional detail dates do not default to today. +EOF +)" +``` + +--- + +### Task 3: Schema + API + Service 写入 + +**Files:** +- Modify: `backend/app/schemas/bx/reimburse.py` +- Modify: `backend/app/api/bx/reimburse.py` +- Modify: `backend/app/services/bx/reimburse.py` + +- [ ] **Step 1: Schema** + +`ReimburseDetailCreate` 增加: + +```python + occur_date: Optional[str] = None +``` + +`ReimburseDetailOut` 增加(文件顶部已有 `OptDateToStr` 导入): + +```python + occur_date: OptDateToStr = None +``` + +- [ ] **Step 2: API `_build_details_data`** + +在 `backend/app/api/bx/reimburse.py` 顶部增加导入: + +```python +from app.utils.bx_codegen import parse_optional_date +``` + +将 `details_data.append({...})` 改为含解析与错误处理: + +```python + try: + occur_date = parse_optional_date(d.occur_date) + except ValueError: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="发生时间格式无效,请使用 YYYY-MM-DD", + ) + details_data.append( + { + "expense_id": d.expense_item_id, + "expense_name": item.item_name, + "amount": d.amount, + "remark": d.remark, + "occur_date": occur_date, + } + ) +``` + +- [ ] **Step 3: Service create / update** + +`create_with_details` 构造 `BxReimburseDetail` 时增加: + +```python + occur_date=d.get("occur_date"), +``` + +`update_with_details` 内同样增加 `occur_date=d.get("occur_date")`。 + +- [ ] **Step 4: 冒烟(可选)** + +重启后端后,用已有登录态调用创建/更新(或 Swagger `/docs`),body detail 含 `"occurDate": "2026-08-01"` 与省略该字段各一次;GET 详情确认 `occurDate` 分别为 `"2026-08-01"` / `null`。 + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/schemas/bx/reimburse.py \ + backend/app/api/bx/reimburse.py \ + backend/app/services/bx/reimburse.py +git commit -m "$(cat <<'EOF' +feat(bx): persist and return reimburse detail occurDate + +Wire optional occur_date through create/update detail payloads and +API responses. +EOF +)" +``` + +--- + +### Task 4: web-admin 申请页录入 + 查看弹窗 + +**Files:** +- Modify: `web-admin/src/views/bx/reimburse/apply/index.vue` + +- [ ] **Step 1: 明细表头与单元格** + +在「报销事由」`