docs: mark OA money field design as implemented

Update status and keep the implementation plan alongside the spec.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-07-30 11:11:13 +08:00
1 parent b5c4ba4c4f
commit 221a6fd360
2 files changed
+663 -3

No files matched your search

@@ -0,0 +1,661 @@
# OA Money Field (金额 + 大写) 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:** Deliver FormCreate `money` control so designers can drag「金额」, configure uppercase display and condition participation, and initiators/approvers see numeric input with optional RMB uppercase (DingTalk-aligned).
**Architecture:** Mirror `dept` / `relatedApproval` four-layer FcDesigner extension: `OaMoneyField.vue` + DragRule + `setupOaMoneyField` on both main app and `oa-fc-host`. Persist `number | null` only; uppercase is display-only via `toRmbUpper`. Backend validates non-negative finite numbers on start. Condition eligibility already includes `money`.
**Tech Stack:** FastAPI · pytest · Vue 3 · FormCreate / `@form-create/designer` · Element Plus · existing `money-format` helpers · Node `node:test` + `tsx` for `rmb-upper` unit tests
**Spec:** `docs/superpowers/specs/2026-07-30-oa-money-field-design.md`
---
## File map
| Path | Responsibility |
|------|----------------|
| Create: `web-admin/src/utils/rmb-upper.ts` | `number` → 人民币大写纯函数 |
| Create: `web-admin/src/utils/rmb-upper.test.ts` | Node test runner cases |
| Create: `web-admin/src/components/oa-approval/OaMoneyField.vue` | Runtime / designer preview 金额控件 |
| Create: `web-admin/src/utils/oa-money-field-rule.ts` | DragRule(`showUpper` 默认 true) |
| Create: `web-admin/src/utils/oa-form-create-money.ts` | `setupOaMoneyField` 注册 |
| Modify: `web-admin/src/main.ts` | 调用 `setupOaMoneyField` |
| Modify: `web-admin/src/oa-fc-host/main.ts` | 同上 |
| Modify: `web-admin/src/utils/oa-approval-print.ts` | 打印金额:数字 + 可选大写 |
| Create: `backend/app/services/oa_money_field.py` | `validate_money_form_values` |
| Create: `backend/tests/test_oa_money_field.py` | 校验单测 |
| Modify: `backend/app/services/oa_engine.py` | `start_instance` 挂入校验 |
| Modify: `backend/app/services/oa_related_approval.py` | 关联摘要格式化金额为千分位数字 |
| Modify: `docs/superpowers/specs/2026-07-30-oa-money-field-design.md` | 状态更新;去掉文末多余 fence |
**已具备、本计划不改:** `CONDITION_ELIGIBLE_TYPES`(前后端均含 `money`)、`oa_publish.ALLOWED_CONTROL_TYPES`、`ConditionRuleEditor` 对 `money` 的数值 coerce。
---
### Task 1: RMB uppercase utility (TDD)
**Files:**
- Create: `web-admin/src/utils/rmb-upper.ts`
- Create: `web-admin/src/utils/rmb-upper.test.ts`
- [ ] **Step 1: Write failing tests**
```ts
// web-admin/src/utils/rmb-upper.test.ts
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import { toRmbUpper } from './rmb-upper'
describe('toRmbUpper', () => {
it('zero', () => {
assert.equal(toRmbUpper(0), '零元整')
})
it('integer', () => {
assert.equal(toRmbUpper(1), '壹元整')
assert.equal(toRmbUpper(10), '壹拾元整')
assert.equal(toRmbUpper(100), '壹佰元整')
assert.equal(toRmbUpper(1234), '壹仟贰佰叁拾肆元整')
})
it('jiao fen', () => {
assert.equal(toRmbUpper(1.23), '壹元贰角叁分')
assert.equal(toRmbUpper(0.5), '伍角')
assert.equal(toRmbUpper(0.05), '伍分')
assert.equal(toRmbUpper(1.2), '壹元贰角')
assert.equal(toRmbUpper(1.03), '壹元零叁分')
})
it('nullish and invalid', () => {
assert.equal(toRmbUpper(null), '')
assert.equal(toRmbUpper(undefined), '')
assert.equal(toRmbUpper(Number.NaN), '')
assert.equal(toRmbUpper(-1), '')
})
it('too large', () => {
assert.equal(toRmbUpper(1e12), '金额过大')
})
it('near max has yuan jiao fen', () => {
const s = toRmbUpper(999999999999.99)
assert.ok(s.includes('元'))
assert.ok(s.includes('角') || s.includes('分'))
assert.notEqual(s, '金额过大')
})
})
```
- [ ] **Step 2: Run tests — expect FAIL**
```bash
cd web-admin && pnpm exec tsx --test src/utils/rmb-upper.test.ts
```
Expected: FAIL(模块不存在或 `toRmbUpper` 未定义)
- [ ] **Step 3: Implement `toRmbUpper`**
Create `web-admin/src/utils/rmb-upper.ts` with a complete algorithm (no pseudo-code left in the file):
- Digits: `零壹贰叁肆伍陆柒捌玖`;section units `万/亿`;int units `拾佰仟`.
- Convert via fen: `Math.round(value * 100)`; reject `value > 999999999999.99` → `金额过大`.
- `null` / `undefined` / `NaN` / negative / non-finite → `''`.
- `0` → `零元整`.
- Integer yuan with no jiao/fen → append `整`.
- Only jiao/fen (no yuan) → e.g. `伍角` / `伍分` without forcing `零元`.
- `1.03` → include `零` before `叁分` as in bank-style wording.
Export: `export function toRmbUpper(value: number | null | undefined): string`
- [ ] **Step 4: Run tests — expect PASS**
```bash
cd web-admin && pnpm exec tsx --test src/utils/rmb-upper.test.ts
```
Expected: PASS
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/utils/rmb-upper.ts web-admin/src/utils/rmb-upper.test.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): add RMB uppercase converter for OA money field
Pure function with node:test coverage for zero, jiao/fen, and range limits.
EOF
)"
```
---
### Task 2: Backend money form_data validation (TDD)
**Files:**
- Create: `backend/app/services/oa_money_field.py`
- Create: `backend/tests/test_oa_money_field.py`
- Modify: `backend/app/services/oa_engine.py`(`start_instance` 中 `validate_dept_form_values` 附近)
- [ ] **Step 1: Write failing tests**
```python
# backend/tests/test_oa_money_field.py
import unittest
from app.services.oa_money_field import validate_money_form_values
class TestValidateMoneyFormValues(unittest.TestCase):
def test_ok_number(self):
schema = {"rule": [{"type": "money", "field": "amt", "title": "金额", "$required": True}]}
self.assertEqual(validate_money_form_values(schema, {"amt": 1234.56}), [])
def test_ok_int(self):
schema = {"rule": [{"type": "money", "field": "amt"}]}
self.assertEqual(validate_money_form_values(schema, {"amt": 10}), [])
def test_ok_null_optional(self):
schema = {"rule": [{"type": "money", "field": "amt", "$required": False}]}
self.assertEqual(validate_money_form_values(schema, {"amt": None}), [])
self.assertEqual(validate_money_form_values(schema, {}), [])
def test_required_missing(self):
schema = {
"rule": [{"type": "money", "field": "amt", "title": "报销金额", "$required": True}]
}
errs = validate_money_form_values(schema, {})
self.assertTrue(any("报销金额" in e for e in errs))
def test_negative_rejected(self):
schema = {"rule": [{"type": "money", "field": "amt"}]}
errs = validate_money_form_values(schema, {"amt": -1})
self.assertTrue(len(errs) >= 1)
def test_string_number_accepted(self):
schema = {"rule": [{"type": "money", "field": "amt"}]}
self.assertEqual(validate_money_form_values(schema, {"amt": "12.5"}), [])
def test_bool_rejected(self):
schema = {"rule": [{"type": "money", "field": "amt"}]}
errs = validate_money_form_values(schema, {"amt": True})
self.assertTrue(len(errs) >= 1)
def test_ignore_other_types(self):
schema = {"rule": [{"type": "input", "field": "x", "$required": True}]}
self.assertEqual(validate_money_form_values(schema, {}), [])
```
- [ ] **Step 2: Run — expect FAIL**
```bash
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_money_field.py -v
```
Expected: FAIL(`oa_money_field` 不存在)
- [ ] **Step 3: Implement validator**
Create `backend/app/services/oa_money_field.py` following the same private helpers pattern as `oa_dept_select.py` (`_walk_rules`, `_schema_rules`, `_is_required`, `_field_label`).
Rules:
- `MAX_MONEY = 999999999999.99`
- Normalize: `int`/`float` OK;数字字符串去逗号后 `float`;`bool` 拒绝;其它类型拒绝
- `None` / `""` → 空;required 时空 → `「{label}」为必填金额字段`
- `< 0` → `「{label}」金额不能为负数`
- `> MAX_MONEY` / 非有限 → 对应错误文案
- 仅处理 `type == "money"`
Export: `validate_money_form_values(schema, form_data) -> List[str]`
- [ ] **Step 4: Wire into `start_instance`**
In `backend/app/services/oa_engine.py` immediately after `validate_dept_form_values`:
```python
from app.services.oa_money_field import validate_money_form_values
money_errs = validate_money_form_values(schema, data)
if money_errs:
raise OaEngineError(money_errs[0])
```
- [ ] **Step 5: Run tests — expect PASS**
```bash
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_money_field.py -v
```
- [ ] **Step 6: Commit**
```bash
git add backend/app/services/oa_money_field.py backend/tests/test_oa_money_field.py backend/app/services/oa_engine.py
git commit -m "$(cat <<'EOF'
feat(backend): validate OA money field values on instance start
Reject negatives, non-finite values, and missing required money controls.
EOF
)"
```
---
### Task 3: DragRule + FormCreate registration
**Files:**
- Create: `web-admin/src/utils/oa-money-field-rule.ts`
- Create: `web-admin/src/utils/oa-form-create-money.ts`
- Create: `web-admin/src/components/oa-approval/OaMoneyField.vue`(最小 stub,Task 4 充实)
- Modify: `web-admin/src/main.ts`
- Modify: `web-admin/src/oa-fc-host/main.ts`
- [ ] **Step 1: Add DragRule**
```ts
// web-admin/src/utils/oa-money-field-rule.ts
let _uidSeq = 0
function uniqueId() {
const num = 370 + ++_uidSeq
return (
'F' +
Math.random().toString(36).slice(3, 6) +
Number(`${Date.now()}`).toString(36) +
num.toString(36)
)
}
export const moneyFieldDragRule = {
menu: 'main' as const,
icon: 'icon-number',
label: '金额',
name: 'money',
input: true,
mask: true,
validate: ['number', 'string'],
event: ['change'],
rule() {
return {
type: 'money',
field: uniqueId(),
title: '金额(元)',
info: '',
$required: false,
props: {
showUpper: true,
placeholder: '请输入金额'
}
}
},
props() {
return [
{
type: 'switch',
field: 'showUpper',
title: '显示大写金额',
value: true,
props: { activeText: '是', inactiveText: '否' }
},
{
type: 'input',
field: 'placeholder',
title: '占位提示',
value: '请输入金额'
},
{
type: 'input',
field: 'formCreateInfo',
title: '提示信息'
}
]
}
}
```
若 `icon-number` 在设计器不显示,改为仓库中部门/数字类已用过的 `fc-icon` 名(打开 `@form-create/designer` 图标表核对)。
- [ ] **Step 2: Stub component + setup helper**
```vue
<!-- web-admin/src/components/oa-approval/OaMoneyField.vue -->
<template>
<div class="oa-money-field">金额</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'money' })
</script>
```
```ts
// web-admin/src/utils/oa-form-create-money.ts
import FcDesigner from '@form-create/designer'
import OaMoneyField from '@/components/oa-approval/OaMoneyField.vue'
import { moneyFieldDragRule } from '@/utils/oa-money-field-rule'
const FLAG = '__oaMoneyFieldConfigured'
export function setupOaMoneyField(formCreate?: { component?: (name: string, c: unknown) => void }) {
if ((FcDesigner as any)[FLAG]) return
FcDesigner.component('money', OaMoneyField)
if (typeof formCreate?.component === 'function') {
formCreate.component('money', OaMoneyField)
}
if (typeof FcDesigner.addDragRule === 'function') {
FcDesigner.addDragRule(moneyFieldDragRule)
}
;(FcDesigner as any)[FLAG] = true
}
```
- [ ] **Step 3: Register both hosts**
In `web-admin/src/main.ts` after `setupOaDeptSelect`:
```ts
import { setupOaMoneyField } from '@/utils/oa-form-create-money'
setupOaMoneyField(FcDesigner.formCreate)
```
Same pair of lines in `web-admin/src/oa-fc-host/main.ts`.
- [ ] **Step 4: Smoke check(人工)**
```bash
cd web-admin && pnpm dev
```
打开审批设计器 →「2 表单设计」→ 左侧「组件」出现「金额」。
- [ ] **Step 5: Commit**
```bash
git add web-admin/src/utils/oa-money-field-rule.ts \
web-admin/src/utils/oa-form-create-money.ts \
web-admin/src/components/oa-approval/OaMoneyField.vue \
web-admin/src/main.ts \
web-admin/src/oa-fc-host/main.ts
git commit -m "$(cat <<'EOF'
feat(web-admin): register OA money field in FormCreate designer
Add DragRule and dual-host setup so 金额 appears in the component palette.
EOF
)"
```
---
### Task 4: Runtime `OaMoneyField.vue`
**Files:**
- Modify: `web-admin/src/components/oa-approval/OaMoneyField.vue`
- [ ] **Step 1: Replace stub with full component**
Behavior per spec §5; structure mirrors `OaDeptSelect` (`disabled` / designer host):
```vue
<template>
<div class="oa-money-field">
<template v-if="isDesignerHost">
<el-input model-value="" :placeholder="placeholder || '请输入金额'" disabled />
<div v-if="showUpper" class="oa-money-field__upper">大写金额预览</div>
</template>
<template v-else-if="disabled">
<div class="oa-money-field__readonly">
<span>{{ displayNumber || '—' }}</span>
<div v-if="showUpper && upperText" class="oa-money-field__upper">{{ upperText }}</div>
</div>
</template>
<template v-else>
<AmountInput
:model-value="numericValue"
:placeholder="placeholder || '请输入金额'"
:min="0"
:precision="2"
@update:model-value="onInput"
/>
<div v-if="showUpper && upperText" class="oa-money-field__upper">{{ upperText }}</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import AmountInput from '@/components/common/amount-input/index.vue'
import { formatMoneyDisplay } from '@/utils/money-format'
import { toRmbUpper } from '@/utils/rmb-upper'
defineOptions({ name: 'money' })
const props = withDefaults(
defineProps<{
modelValue?: number | string | null
disabled?: boolean
showUpper?: boolean
placeholder?: string
formCreateInject?: Record<string, unknown>
}>(),
{ showUpper: true, placeholder: '请输入金额' }
)
const emit = defineEmits<{
(e: 'update:modelValue', v: number | null): void
(e: 'change', v: number | null): void
}>()
function detectDesignerHost() {
try {
return /oa-fc-host\.html/i.test(String(window.location?.pathname || ''))
} catch {
return false
}
}
const isDesignerHost = detectDesignerHost()
function toNum(v: unknown): number | null {
if (v == null || v === '') return null
const n = typeof v === 'number' ? v : Number(String(v).replace(/,/g, ''))
if (!Number.isFinite(n) || n < 0) return null
return Math.round(n * 100) / 100
}
const numericValue = computed(() => toNum(props.modelValue))
const displayNumber = computed(() => formatMoneyDisplay(numericValue.value, 2))
const upperText = computed(() => {
const n = numericValue.value
if (n == null) return ''
return toRmbUpper(n)
})
function onInput(v: number | null) {
const next = v == null || v < 0 ? null : Math.round(Number(v) * 100) / 100
emit('update:modelValue', next)
emit('change', next)
}
</script>
<style scoped>
.oa-money-field__upper {
margin-top: 6px;
font-size: 12px;
line-height: 1.4;
color: var(--mint-text-muted, #64748b);
}
</style>
```
Constraints:
- Persist `number | null` only; never emit uppercase string.
- `showUpper` comes from rule `props` (FormCreate expands props onto the component).
- Explicitly import `AmountInput` (same as production-material).
- [ ] **Step 2: Manual verify**
1. Designer: drag 金额;「显示大写金额」default on.
2. Publish + start: enter `1234.56`, uppercase shows; toggle off → no uppercase line.
3. Detail readonly: number + uppercase.
4. Check「参与流程条件分支」→ required locked → field appears in flow condition candidates.
- [ ] **Step 3: Commit**
```bash
git add web-admin/src/components/oa-approval/OaMoneyField.vue
git commit -m "$(cat <<'EOF'
feat(web-admin): implement OA money field with optional RMB uppercase
Numeric AmountInput plus muted uppercase line; designer host shows preview only.
EOF
)"
```
---
### Task 5: Print + related-summary formatting
**Files:**
- Modify: `web-admin/src/utils/oa-approval-print.ts`
- Modify: `backend/app/services/oa_related_approval.py`
- [ ] **Step 1: Print path**
In `oa-approval-print.ts`, beside the `dept` branch:
```ts
import { toRmbUpper } from '@/utils/rmb-upper'
import { formatMoneyDisplay } from '@/utils/money-format'
if (type === 'money') {
const showUpper = rule.props?.showUpper !== false
const n =
value == null || value === ''
? null
: typeof value === 'number'
? value
: Number(String(value).replace(/,/g, ''))
if (n == null || !Number.isFinite(n)) {
out.push({ label, text: '-' })
continue
}
const numText = formatMoneyDisplay(n, 2)
const upper = showUpper ? toRmbUpper(n) : ''
out.push({
label,
text: upper ? `${numText}(${upper})` : numText
})
continue
}
```
Use `(大写)` inline so single-line print rows remain compatible with `OaApprovalPrintSheet`.
- [ ] **Step 2: Related approval summary**
In `oa_related_approval.py` flatten display (next to `typ == "dept"`):
```python
if typ == "money":
if raw is None or raw == "":
return "", False
try:
if isinstance(raw, bool):
return "", False
v = float(str(raw).replace(",", "").strip())
except (TypeError, ValueError):
return str(raw), False
text = f"{v:,.2f}"
return text, False
```
- [ ] **Step 3: Manual print check** on an instance that includes a money field.
- [ ] **Step 4: Commit**
```bash
git add web-admin/src/utils/oa-approval-print.ts backend/app/services/oa_related_approval.py
git commit -m "$(cat <<'EOF'
feat(oa): format money fields in print and related-approval summary
Show formatted amount and optional uppercase on print; summary uses numeric text.
EOF
)"
```
---
### Task 6: Spec status + verification
**Files:**
- Modify: `docs/superpowers/specs/2026-07-30-oa-money-field-design.md`
- [ ] **Step 1: Spec polish**
- Set status to `已实现`.
- Remove the stray trailing ` ``` ` at end of §10 if still present.
- [ ] **Step 2: Regression commands**
```bash
cd web-admin && pnpm exec tsx --test src/utils/rmb-upper.test.ts
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_money_field.py tests/test_oa_publish.py -v --tb=short
cd web-admin && pnpm build:typecheck
```
Expected: tests pass; typecheck has no new errors from this change.
- [ ] **Step 3: Acceptance checklist(spec §9)**
1. Designer palette shows「金额」
2.「显示大写金额」default on; toggle works
3. Condition participation + required lock + flow candidates
4. Start form: `1234.56` + uppercase; `form_data` numeric
5. Uppercase off → no uppercase line
6. Detail/print consistent
7. `rmb-upper` tests pass
- [ ] **Step 4: Commit**
```bash
git add docs/superpowers/specs/2026-07-30-oa-money-field-design.md
git commit -m "$(cat <<'EOF'
docs: mark OA money field design as implemented
Update status after money control delivery and remove stray fence.
EOF
)"
```
- [ ] **Step 5: Update knowledge graph**
```bash
./scripts/update-graph.sh all
```
---
## Spec coverage self-check
| Spec 项 | Task |
|---------|------|
| 自定义 `money` + 双侧注册 | 3–4 |
| `showUpper` 默认 true | 3–4 |
| 只存 number | 2 + 4 |
| 大写实时换算 / 范围 / 单测 | 1 |
| 参与条件(现有基础设施) | 4 手工验收 |
| 后端发起校验 | 2 |
| 打印 + 大写 | 5 |
| 非目标(多币种/小程序/落库大写) | 不实现 |
## Placeholder scan
No TBD steps. Icon name may need one-line swap if `icon-number` missing from FcDesigner icon set.
---
## Execution handoff
Plan complete and saved to `docs/superpowers/plans/2026-07-30-oa-money-field.md`.
**Two execution options:**
1. **Subagent-Driven (recommended)** — fresh subagent per task, review between tasks
2. **Inline Execution** — execute in this session with `executing-plans` and checkpoints
Which approach?
@@ -3,7 +3,7 @@
**日期:** 2026-07-30
**范围:** `web-admin` 审批设计器步骤 2(表单设计)+ 发起/详情/打印运行时;条件分支已预留 `money`;后端发布白名单已含 `money`
**关联:** `docs/superpowers/specs/2026-07-14-oa-approval-dingtalk-design.md`;`docs/superpowers/specs/2026-07-15-oa-condition-from-form-design.md`;钉钉开放平台 `MoneyField`
**状态:** 待用户评审
**状态:** 已实现
---
@@ -187,5 +187,4 @@ type MoneyFormValue = number | null
- FormCreate 若内置同名 `money` 控件,注册时需确认覆盖策略与 DragRule `name` 不冲突;以本仓库自定义组件为准。
- 千分位仅影响输入展示,条件与存值必须是纯 number,避免字符串比较。
- Tailwind 不进入 `oa-fc-host`;设计器内预览样式写在宿主局部 CSS。
```
- Tailwind 不进入 `oa-fc-host`;设计器内预览样式写在宿主局部 CSS。