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>
This commit is contained in:
zlwuandCursor committed 2026-08-17 15:48:02 +08:00
1 parent 071281150d
commit f73fb572d1
1 file changed
+552
@@ -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`。