2026-07-30-oa-money-field.md 20 KB

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

// 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
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
cd web-admin && pnpm exec tsx --test src/utils/rmb-upper.test.ts

Expected: PASS

  • Step 5: Commit
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

# 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
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:

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
cd backend && source venv/bin/activate && python -m pytest tests/test_oa_money_field.py -v
  • Step 6: Commit
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

// 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
<!-- 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
}
  • Step 3: Register both hosts

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.

  • Step 4: Smoke check(人工)
cd web-admin && pnpm dev

打开审批设计器 →「2 表单设计」→ 左侧「组件」出现「金额」。

  • Step 5: Commit
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):

<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
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:

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"):

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
)"

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

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
  2. Condition participation + required lock + flow candidates
  3. Start form: 1234.56 + uppercase; form_data numeric
  4. Uppercase off → no uppercase line
  5. Detail/print consistent
  6. rmb-upper tests pass
  • Step 4: Commit
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
./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?