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
| 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。
Files:
web-admin/src/utils/rmb-upper.tsCreate: web-admin/src/utils/rmb-upper.test.ts
[ ] Step 1: Write failing tests
// 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, '金额过大')
})
})
cd web-admin && pnpm exec tsx --test src/utils/rmb-upper.test.ts
Expected: FAIL(模块不存在或 toRmbUpper 未定义)
toRmbUpperCreate web-admin/src/utils/rmb-upper.ts with a complete algorithm (no pseudo-code left in the file):
零壹贰叁肆伍陆柒捌玖;section units 万/亿;int units 拾佰仟.Math.round(value * 100); reject value > 999999999999.99 → 金额过大.null / undefined / NaN / negative / non-finite → ''.0 → 零元整.整.伍角 / 伍分 without forcing 零元.1.03 → include 零 before 叁分 as in bank-style wording.Export: export function toRmbUpper(value: number | null | undefined): string
cd web-admin && pnpm exec tsx --test src/utils/rmb-upper.test.ts
Expected: PASS
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
)"
Files:
backend/app/services/oa_money_field.pybackend/tests/test_oa_money_field.pyModify: backend/app/services/oa_engine.py(start_instance 中 validate_dept_form_values 附近)
[ ] Step 1: Write failing tests
# 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, {}), [])
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_money_field.py -v
Expected: FAIL(oa_money_field 不存在)
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.99int/float OK;数字字符串去逗号后 float;bool 拒绝;其它类型拒绝None / "" → 空;required 时空 → 「{label}」为必填金额字段< 0 → 「{label}」金额不能为负数> MAX_MONEY / 非有限 → 对应错误文案type == "money"Export: validate_money_form_values(schema, form_data) -> List[str]
start_instanceIn backend/app/services/oa_engine.py immediately after validate_dept_form_values:
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])
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_money_field.py -v
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
)"
Files:
web-admin/src/utils/oa-money-field-rule.tsweb-admin/src/utils/oa-form-create-money.tsweb-admin/src/components/oa-approval/OaMoneyField.vue(最小 stub,Task 4 充实)web-admin/src/main.tsModify: web-admin/src/oa-fc-host/main.ts
[ ] Step 1: Add DragRule
// 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 图标表核对)。
<!-- web-admin/src/components/oa-approval/OaMoneyField.vue -->
<template>
<div class="oa-money-field">金额</div>
</template>
<script setup lang="ts">
defineOptions({ name: 'money' })
</script>
// 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
}
In web-admin/src/main.ts after setupOaDeptSelect:
import { setupOaMoneyField } from '@/utils/oa-form-create-money'
setupOaMoneyField(FcDesigner.formCreate)
Same pair of lines in web-admin/src/oa-fc-host/main.ts.
cd web-admin && pnpm dev
打开审批设计器 →「2 表单设计」→ 左侧「组件」出现「金额」。
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
)"
OaMoneyField.vueFiles:
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):
<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:
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
1234.56, uppercase shows; toggle off → no uppercase line.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
)"
Files:
web-admin/src/utils/oa-approval-print.tsModify: backend/app/services/oa_related_approval.py
[ ] Step 1: Print path
In oa-approval-print.ts, beside the dept branch:
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.
In oa_related_approval.py flatten display (next to typ == "dept"):
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
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
)"
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
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.
1234.56 + uppercase; form_data numericrmb-upper tests passgit 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
)"
./scripts/update-graph.sh all
| Spec 项 | Task |
|---|---|
自定义 money + 双侧注册 |
3–4 |
showUpper 默认 true |
3–4 |
| 只存 number | 2 + 4 |
| 大写实时换算 / 范围 / 单测 | 1 |
| 参与条件(现有基础设施) | 4 手工验收 |
| 后端发起校验 | 2 |
| 打印 + 大写 | 5 |
| 非目标(多币种/小程序/落库大写) | 不实现 |
No TBD steps. Icon name may need one-line swap if icon-number missing from FcDesigner icon set.
Plan complete and saved to docs/superpowers/plans/2026-07-30-oa-money-field.md.
Two execution options:
executing-plans and checkpointsWhich approach?