浏览代码

docs: add implementation plan for reimburse detail occur date

Break the approved design into backend, web-admin, and bx-mobile
tasks with concrete steps and verification.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 月之前
父节点
当前提交
f73fb572d1
共有 1 个文件被更改,包括 552 次插入 和 0 次删除
  1. 552 0
      docs/superpowers/plans/2026-08-17-reimburse-detail-occur-date.md

+ 552 - 0
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: 明细表头与单元格**
+
+在「报销事由」`<th>` / `<td>` 之后、「操作」之前插入:
+
+```vue
+                <th class="col-occur">发生时间</th>
+```
+
+```vue
+                <td class="col-occur">
+                  <el-date-picker
+                    v-model="line.occurDate"
+                    type="date"
+                    value-format="YYYY-MM-DD"
+                    placeholder="可选"
+                    clearable
+                    style="width: 100%"
+                  />
+                </td>
+```
+
+- [ ] **Step 2: 类型与表单初始化**
+
+`DetailLine`:
+
+```typescript
+interface DetailLine {
+  expenseItemId: string
+  amount: number | string
+  remark: string
+  occurDate: string
+}
+```
+
+所有初始化 / `addDetailLine` / `reset` / 空行默认值改为含 `occurDate: ''`。
+
+编辑回填:
+
+```typescript
+    addForm.details = (data.details || []).map((d: any) => ({
+      expenseItemId: d.expenseId,
+      amount: d.amount,
+      remark: d.remark || '',
+      occurDate: d.occurDate || ''
+    }))
+```
+
+提交 `buildPayload` 的 details map:
+
+```typescript
+    details: validDetails.map((d) => ({
+      expenseItemId: String(d.expenseItemId),
+      amount: Number(d.amount),
+      remark: String(d.remark).trim(),
+      occurDate: d.occurDate ? String(d.occurDate) : undefined
+    }))
+```
+
+- [ ] **Step 3: 查看弹窗列**
+
+在报销事由列后:
+
+```vue
+            <el-table-column prop="occurDate" label="发生时间" min-width="120" align="center" header-align="center">
+              <template #default="{ row }">{{ row.occurDate || '—' }}</template>
+            </el-table-column>
+```
+
+- [ ] **Step 4: 手工验收**
+
+打开 `/#/bx/reimburse/apply`:新增一行填发生时间、一行不填 → 保存 → 查看弹窗分别显示日期与 `—`;编辑可清空。
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add web-admin/src/views/bx/reimburse/apply/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): add optional occur date on reimburse apply details
+
+Allow picking YYYY-MM-DD per detail line and show it in the view dialog.
+EOF
+)"
+```
+
+---
+
+### Task 5: web-admin 只读明细列(审批 + view 三页)
+
+**Files:**
+- 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`
+
+- [ ] **Step 1: 四处明细表在「报销事由」列后增加同一列**
+
+```vue
+            <el-table-column prop="occurDate" label="发生时间" min-width="120" align="center" header-align="center">
+              <template #default="{ row }">{{ row.occurDate || '—' }}</template>
+            </el-table-column>
+```
+
+若某页「报销事由」列模板写法略有不同,仍插在其后。
+
+- [ ] **Step 2: 手工抽查**
+
+分别打开审批页与我的/待办/全部查看弹窗,确认列存在且空值为 `—`。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add web-admin/src/views/bx/reimburse/approve/index.vue \
+  web-admin/src/views/bx/view/mine/index.vue \
+  web-admin/src/views/bx/view/todo/index.vue \
+  web-admin/src/views/bx/view/all/index.vue
+git commit -m "$(cat <<'EOF'
+feat(web-admin): show occur date on reimburse detail readonly tables
+
+Keep the column always visible with an em dash when empty.
+EOF
+)"
+```
+
+---
+
+### Task 6: bx-mobile 申请页
+
+**Files:**
+- Modify: `bx-mobile/src/views/reimburse/ReimburseApplyPage.vue`
+
+- [ ] **Step 1: 模板 — 报销事由后增加字段**
+
+对齐已有 `reimburseDate` 的 `input type="date"`:
+
+```vue
+            <!-- 发生时间(可选) -->
+            <div class="field">
+              <label class="field-label">发生时间</label>
+              <input
+                class="form-input"
+                type="date"
+                v-model="line.occurDate"
+              />
+            </div>
+```
+
+- [ ] **Step 2: DetailLine 与数据流**
+
+```typescript
+type DetailLine = {
+  expenseItemId: string | number
+  amount: string | number
+  remark: string
+  occurDate: string
+}
+```
+
+初始化 / `addDetailLine` / 清空默认行:`occurDate: ''`。
+
+回填:
+
+```typescript
+      occurDate: d.occurDate || '',
+```
+
+提交 details:
+
+```typescript
+        remark: String(d.remark).trim(),
+        occurDate: d.occurDate ? String(d.occurDate) : undefined,
+```
+
+校验逻辑**不要**要求 `occurDate` 必填。
+
+- [ ] **Step 3: 手工验收**
+
+移动端申请:有值/无值各保存一次,编辑回填正确。
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add bx-mobile/src/views/reimburse/ReimburseApplyPage.vue
+git commit -m "$(cat <<'EOF'
+feat(bx-mobile): add optional occur date on reimburse apply form
+
+Mirror web-admin detail lines with a clearable YYYY-MM-DD input.
+EOF
+)"
+```
+
+---
+
+### Task 7: bx-mobile 详情 / 审批只读
+
+**Files:**
+- Modify: `bx-mobile/src/views/reimburse/ReimburseDetailPage.vue`
+- Modify: `bx-mobile/src/views/reimburse/ReimburseApprovePage.vue`
+
+- [ ] **Step 1: 明细块在报销事由后始终展示发生时间**
+
+两处都改为(与现有 `v-if="d.remark"` 的事由不同,发生时间**始终**渲染):
+
+```vue
+                <div class="adv-field">
+                  <div class="adv-field__label">发生时间</div>
+                  <div class="adv-field__value">{{ d.occurDate || '—' }}</div>
+                </div>
+```
+
+插在「报销事由」字段之后。
+
+- [ ] **Step 2: 手工验收**
+
+详情页与审批页:有值显示日期,无值 `—`。
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add bx-mobile/src/views/reimburse/ReimburseDetailPage.vue \
+  bx-mobile/src/views/reimburse/ReimburseApprovePage.vue
+git commit -m "$(cat <<'EOF'
+feat(bx-mobile): display occur date on reimburse detail views
+
+Always show the field and use an em dash when the date is empty.
+EOF
+)"
+```
+
+---
+
+### Task 8: 收尾 — 图谱更新 + 总验收
+
+**Files:** 无业务代码(图谱增量)
+
+- [ ] **Step 1: 更新知识图谱**
+
+```bash
+./scripts/update-graph.sh all
+```
+
+或按仓库惯例对改动端执行图谱更新。
+
+- [ ] **Step 2: 总验收清单**
+
+1. DB 列存在;旧数据 `NULL`
+2. Web 申请:填/不填/清空均可保存提交
+3. Web 查看/审批/view 三页:列可见,空为 `—`
+4. Mobile 申请 + 详情 + 审批一致
+5. `pytest tests/test_bx_optional_date.py -v` PASS
+
+- [ ] **Step 3: Commit 图谱(若有变更)**
+
+```bash
+git add backend/graphify-out web-admin/graphify-out bx-mobile/graphify-out graphify-out 2>/dev/null || true
+git status
+# 仅提交实际变更的图谱文件
+git commit -m "$(cat <<'EOF'
+chore: update graphify after reimburse occur_date changes
+EOF
+)"
+```
+
+---
+
+## Spec 覆盖自检
+
+| Spec 项 | Task |
+|---------|------|
+| D1 `occur_date` DATE 可空 | Task 1 |
+| D2 仅 YYYY-MM-DD | Task 2–7 |
+| D3 可选、空→NULL | Task 2–3、4、6 |
+| D4 只读始终显示、空为 `—` | Task 4–5、7 |
+| D5 三端 | Task 3–7 |
+| D6 不做筛选/导出等 | 无对应 Task(刻意不做) |
+| 迁移 + shanxia2.0.sql | Task 1 |
+| `_build_details_data` + service | Task 3 |
+| ApprovalDetailPage 不改 | 无 Task |
+
+**占位符扫描:** 无 TBD/TODO。  
+**类型一致性:** DB/Model `occur_date`;API JSON `occurDate`;前端 `occurDate`。