Files
shanxia2.0/docs/superpowers/plans/2026-08-20-crm-invoice-paste-recognize.md
T
2026-08-21 09:35:11 +08:00

17 KiB
Raw Blame History

CRM 开票信息粘贴快速识别 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: 在客户开票信息新增/编辑弹窗顶部增加「粘贴识别」区,用纯前端规则解析常见开票资料文本并覆盖填入表单字段。

Architecture: 解析逻辑独立为纯函数工具 crm-invoice-paste-recognize.ts(可单测);InvoiceInfo.vue 仅负责 UI 与回填。无后端/大模型依赖。未映射字段(行号、邮编、邮寄)追加写入 remark。

Tech Stack: Vue 3 + Element Plus;Node tsx --test + node:test(与 pl-cert-slots.test.ts 一致)

Spec: docs/superpowers/specs/2026-08-20-crm-invoice-paste-recognize-design.md
Demo: docs/demo-crm-invoice-paste-recognize.html


File map

操作 文件 职责
Create web-admin/src/utils/crm-invoice-paste-recognize.ts 解析开票粘贴文本 → 结构化结果
Create web-admin/src/utils/crm-invoice-paste-recognize.test.ts 两段业务样例 + 边界单测
Modify web-admin/src/views/crm/customer/components/InvoiceInfo.vue 弹窗顶部粘贴区 + 识别回填
Optional docs/demo-crm-invoice-paste-recognize.html 若与实现规则漂移则同步
Modify graphify ./scripts/update-graph.sh frontend

Commit 约定: 仅在用户明确要求时执行 git commit;计划中的 Commit 步骤默认跳过。


Task 1: 解析工具单测(先红)

Files:

  • Create: web-admin/src/utils/crm-invoice-paste-recognize.test.ts

  • Create: web-admin/src/utils/crm-invoice-paste-recognize.ts(本 Task 仅写最小 stub,使测试可 import;完整实现在 Task 2)

  • Step 1: 写入失败测试(完整样例断言)

创建 web-admin/src/utils/crm-invoice-paste-recognize.test.ts:

import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
  CRM_INVOICE_PASTE_SAMPLE_ANQING,
  CRM_INVOICE_PASTE_SAMPLE_JIANGFENG,
  parseCrmInvoicePaste,
  mergeInvoiceRemark
} from './crm-invoice-paste-recognize'

describe('parseCrmInvoicePaste · 安庆山峡样例', () => {
  it('maps core fields and packs extra into remarkParts', () => {
    const r = parseCrmInvoicePaste(CRM_INVOICE_PASTE_SAMPLE_ANQING)
    assert.equal(r.companyName, '安庆市山峡化工有限公司')
    assert.equal(r.taxNo, '9134080006084212XH')
    assert.equal(r.bankName, '中国建设银行安庆分行城中支行')
    assert.equal(r.bankAccount, '34050168860800000121')
    assert.equal(r.companyAddress, '安徽省安庆市高新区霞虹路5号')
    assert.equal(r.phoneFax, '0556-5348918')
    assert.ok(r.filledCount >= 6)
    assert.ok(r.remarkParts.some((p) => p.includes('银行行号') && p.includes('105368000056')))
    assert.ok(r.remarkParts.some((p) => p.includes('邮编') && p.includes('246000')))
    assert.ok(r.remarkParts.some((p) => p.includes('邮寄') && p.includes('汪璇')))
    // 邮寄块内地址不得覆盖单位地址
    assert.equal(r.companyAddress, '安徽省安庆市高新区霞虹路5号')
  })
})

describe('parseCrmInvoicePaste · 江峰生物样例', () => {
  it('handles spaced labels, spaced account, and 地址及电话', () => {
    const r = parseCrmInvoicePaste(CRM_INVOICE_PASTE_SAMPLE_JIANGFENG)
    assert.equal(r.companyName, '安徽江峰生物科技有限公司')
    assert.equal(r.taxNo, '91340200MA8PYJNB8X')
    assert.equal(r.bankName, '中国建设银行芜湖经开区支行')
    assert.equal(r.bankAccount, '34050167880800002481')
    assert.ok((r.companyAddress || '').includes('安徽省芜湖市'))
    assert.ok(!(r.companyAddress || '').includes('0553'))
    assert.equal(r.phoneFax, '0553-5201019')
    assert.ok(r.remarkParts.some((p) => p.includes('银行行号') && p.includes('105362050066')))
  })
})

describe('parseCrmInvoicePaste · 边界', () => {
  it('returns empty filledCount for blank text', () => {
    const r = parseCrmInvoicePaste('   \n  ')
    assert.equal(r.filledCount, 0)
    assert.ok(r.warnings.length > 0)
  })

  it('does not treat 名称 inside unrelated prose without colon as company', () => {
    const r = parseCrmInvoicePaste('请查收开票资料,谢谢')
    assert.equal(r.filledCount, 0)
  })
})

describe('mergeInvoiceRemark', () => {
  it('appends new parts without duplicating existing', () => {
    const next = mergeInvoiceRemark('已有备注', ['银行行号:105', '邮编:246000'])
    assert.equal(next, '已有备注;银行行号:105;邮编:246000')
    const again = mergeInvoiceRemark(next, ['银行行号:105', '邮寄:张三'])
    assert.equal(again, '已有备注;银行行号:105;邮编:246000;邮寄:张三')
  })

  it('returns existing when parts empty', () => {
    assert.equal(mergeInvoiceRemark('x', []), 'x')
    assert.equal(mergeInvoiceRemark('', ['a']), 'a')
  })
})
  • Step 2: 写入最小 stub(使 import 不炸,测试应失败)

创建 web-admin/src/utils/crm-invoice-paste-recognize.ts:

export const CRM_INVOICE_PASTE_SAMPLE_ANQING = `开票资料:
公司名称:安庆市山峡化工有限公司
纳税人识别号:9134080006084212XH
开户行:中国建设银行安庆分行城中支行
帐号:34050168860800000121
银行行号:105368000056
地址:安徽省安庆市高新区霞虹路5号
电话/传真:0556-5348918
邮编:246000
发票邮寄地址:
姓名:汪璇
电话:18895395317
地址:安徽省安庆市高新区霞虹路5号`

export const CRM_INVOICE_PASTE_SAMPLE_JIANGFENG = `单位名:安徽江峰生物科技有限公司
税  号:91340200MA8PYJNB8X
地址及电话:安徽省芜湖市皖江江北新兴产业集中区沈巷片区起步区皖跃路与和睦路交叉口西南角福州路4号
银行开户行:中国建设银行芜湖经开区支行
账  号:3405  0167  8808  0000  2481
银行行号:1053  6205  0066
 电话:0553-5201019`

export interface CrmInvoicePasteResult {
  companyName?: string
  taxNo?: string
  companyAddress?: string
  legalRepresentative?: string
  agent?: string
  phoneFax?: string
  bankName?: string
  bankAccount?: string
  remarkParts: string[]
  warnings: string[]
  filledCount: number
}

export function parseCrmInvoicePaste(_raw: string): CrmInvoicePasteResult {
  return { remarkParts: [], warnings: ['not implemented'], filledCount: 0 }
}

export function mergeInvoiceRemark(existing: string | undefined, parts: string[]): string {
  return (existing || '').trim()
}
  • Step 3: 跑测试确认失败
cd web-admin && pnpm exec tsx --test src/utils/crm-invoice-paste-recognize.test.ts

Expected: FAIL(断言 companyName 等不符)

  • Step 4: Commit(仅用户要求时) — 跳过默认

Task 2: 实现解析工具(转绿)

Files:

  • Modify: web-admin/src/utils/crm-invoice-paste-recognize.ts

  • Step 1: 用完整实现替换 stub

将 parseCrmInvoicePaste / mergeInvoiceRemark 换成下列逻辑(可整文件替换 stub 导出之后的实现部分;样例常量与 interface 保留):

function normalizeSpaces(s: string): string {
  return (s || '').replace(/\s+/g, ' ').trim()
}

function splitMailBlock(text: string): { main: string; mail: string } {
  const idx = text.search(/发票邮寄地址\s*[::]?/)
  if (idx < 0) return { main: text, mail: '' }
  return {
    main: text.slice(0, idx),
    mail: text.slice(idx).replace(/^发票邮寄地址\s*[::]?/, '')
  }
}

function pickLine(block: string, labelRe: RegExp): string {
  const lines = block.split(/\r?\n/)
  for (const raw of lines) {
    const line = raw.trim()
    if (!line) continue
    const m = line.match(labelRe)
    if (m && m[1] != null) return normalizeSpaces(m[1])
  }
  return ''
}

const PHONE_TAIL =
  /((?:0\d{2,3}-?\d{7,8})|(?:1\d{10}))\s*$/

export function parseCrmInvoicePaste(raw: string): CrmInvoicePasteResult {
  const warnings: string[] = []
  const text = (raw || '').replace(/\r\n/g, '\n').trim()
  if (!text) {
    return { remarkParts: [], warnings: ['请先粘贴开票资料文本'], filledCount: 0 }
  }

  const { main, mail } = splitMailBlock(text)
  const remarkParts: string[] = []

  const companyName = pickLine(
    main,
    /^(?:公司名称|单位名称|单位名|名称)\s*[::]\s*(.+)$/
  )
  const taxNo = pickLine(
    main,
    /^(?:纳税人识别号|纳税识别号|统一社会信用代码|税\s*号|税号)\s*[::]\s*([0-9A-Za-z]+)\s*$/
  )
  const bankName = pickLine(
    main,
    /^(?:银行开户行|开户银行|开户行)\s*[::]\s*(.+)$/
  )
  let bankAccount = pickLine(
    main,
    /^(?:银行账号|账\s*号|帐\s*号|账号|帐号)\s*[::]\s*([0-9\s]+)\s*$/
  )
  if (bankAccount) bankAccount = bankAccount.replace(/\s+/g, '')

  let companyAddress = ''
  let phoneFax = ''

  const addrPhone = pickLine(main, /^(?:地址及电话)\s*[::]\s*(.+)$/)
  if (addrPhone) {
    const m = addrPhone.match(PHONE_TAIL)
    if (m && m.index != null) {
      phoneFax = m[1]
      companyAddress = normalizeSpaces(addrPhone.slice(0, m.index))
    } else {
      companyAddress = addrPhone
    }
  } else {
    companyAddress = pickLine(main, /^(?:单位地址|地\s*址|地址)\s*[::]\s*(.+)$/)
  }

  if (!phoneFax) {
    phoneFax =
      pickLine(main, /^(?:电话\/传真|电话传真|传真)\s*[::]\s*(.+)$/) ||
      pickLine(main, /^(?:电\s*话|电话)\s*[::]\s*(.+)$/)
  }

  const legalRepresentative = pickLine(
    main,
    /^(?:法定代表人|法人代表|法人)\s*[::]\s*(.+)$/
  )
  const agent = pickLine(main, /^(?:委托代理人|代理人)\s*[::]\s*(.+)$/)

  const bankCode = pickLine(main, /^(?:银行行号|联行号|行号)\s*[::]\s*([0-9\s]+)\s*$/)
  if (bankCode) remarkParts.push(`银行行号:${bankCode.replace(/\s+/g, '')}`)

  const zip = pickLine(main, /^(?:邮\s*编|邮编)\s*[::]\s*(\d{6})\s*$/)
  if (zip) remarkParts.push(`邮编:${zip}`)

  if (mail.trim()) {
    const name = pickLine(mail, /^(?:姓\s*名|姓名)\s*[::]\s*(.+)$/)
    const phone = pickLine(mail, /^(?:电\s*话|电话|手机)\s*[::]\s*(.+)$/)
    const addr = pickLine(mail, /^(?:地\s*址|地址)\s*[::]\s*(.+)$/)
    const bits = [name && `收件人 ${name}`, phone, addr].filter(Boolean) as string[]
    if (bits.length) remarkParts.push(`邮寄:${bits.join(' / ')}`)
  }

  const fields = {
    companyName: companyName || undefined,
    taxNo: taxNo || undefined,
    companyAddress: companyAddress || undefined,
    legalRepresentative: legalRepresentative || undefined,
    agent: agent || undefined,
    phoneFax: phoneFax || undefined,
    bankName: bankName || undefined,
    bankAccount: bankAccount || undefined
  }

  const filledCount = Object.values(fields).filter(Boolean).length
  if (!companyName) warnings.push('未识别到单位名称')
  if (!taxNo) warnings.push('未识别到税号')
  if (filledCount === 0) warnings.push('未能识别任何开票字段,请检查格式或手动填写')

  return { ...fields, remarkParts, warnings, filledCount }
}

export function mergeInvoiceRemark(existing: string | undefined, parts: string[]): string {
  const base = (existing || '').trim()
  const extras = (parts || []).map((p) => p.trim()).filter(Boolean)
  if (!extras.length) return base
  let next = base
  for (const p of extras) {
    if (!next) {
      next = p
      continue
    }
    if (next.includes(p)) continue
    next = `${next};${p}`
  }
  return next
}

注意:江峰样例「地址及电话」行没有末尾电话(电话在单独「电话:」行),PHONE_TAIL 不应误切地址;电话由后续 电话 行填入。安庆样例「地址」在 main 段、邮寄段另有「地址」——splitMailBlock 保证单位地址只从 main 取。

  • Step 2: 跑测试确认通过
cd web-admin && pnpm exec tsx --test src/utils/crm-invoice-paste-recognize.test.ts

Expected: 全部 PASS

若江峰「地址及电话」行实际含电话或格式有空格差异导致失败:按失败断言微调 pickLine / 样例常量,保持业务语义。

  • Step 3: Commit(仅用户要求时) — 跳过默认

Task 3: InvoiceInfo.vue 接入粘贴识别 UI

Files:

  • Modify: web-admin/src/views/crm/customer/components/InvoiceInfo.vue

  • Step 1: template — 在 el-form 内、.form-grid-2 前插入粘贴面板

在 <el-form ... class="crm-dialog-form"> 后立即加入:

        <div class="invoice-paste-panel">
          <div class="invoice-paste-panel__hd">
            <span class="invoice-paste-panel__title">粘贴识别</span>
            <span class="invoice-paste-panel__tip">粘贴开票资料文本后点「识别并填入」,将覆盖下方对应字段</span>
          </div>
          <el-input
            v-model="pasteText"
            type="textarea"
            :rows="5"
            placeholder="在此粘贴客户发来的开票资料…"
          />
          <div class="invoice-paste-panel__actions">
            <el-button class="btn-ghost" @click="clearPaste">
              <el-icon><Delete /></el-icon>清空
            </el-button>
            <el-button class="btn-main" type="primary" @click="handleRecognizePaste">
              <el-icon><DocumentCopy /></el-icon>识别并填入
            </el-button>
          </div>
        </div>
  • Step 2: script — import、状态、处理函数
  1. 图标增加 DocumentCopy(已有 Delete)。
  2. 增加:
import {
  parseCrmInvoicePaste,
  mergeInvoiceRemark
} from '@/utils/crm-invoice-paste-recognize'

const pasteText = ref('')

const clearPaste = () => {
  pasteText.value = ''
}

const handleRecognizePaste = () => {
  const parsed = parseCrmInvoicePaste(pasteText.value)
  if (!parsed.filledCount && !parsed.remarkParts.length) {
    ElMessage.warning(parsed.warnings[0] || '未能识别,请检查格式或手动填写')
    return
  }
  const keys = [
    'companyName',
    'taxNo',
    'companyAddress',
    'legalRepresentative',
    'agent',
    'phoneFax',
    'bankName',
    'bankAccount'
  ] as const
  for (const k of keys) {
    const v = parsed[k]
    if (v) form.value[k] = v
  }
  if (parsed.remarkParts.length) {
    form.value.remark = mergeInvoiceRemark(form.value.remark, parsed.remarkParts)
  }
  const tip =
    `已识别 ${parsed.filledCount} 个字段` +
    (parsed.remarkParts.length ? `,另有 ${parsed.remarkParts.length} 项写入备注` : '') +
    (parsed.warnings.length ? `;${parsed.warnings.join(';')}` : '')
  if (parsed.warnings.length && parsed.filledCount === 0) {
    ElMessage.warning(tip)
  } else if (parsed.warnings.length) {
    ElMessage.warning(tip)
  } else {
    ElMessage.success(tip)
  }
}
  1. 在 reset 末尾增加 pasteText.value = ''。
  • Step 3: scoped 样式
.invoice-paste-panel {
  margin-bottom: 16px;
  padding: 12px;
  border: 1px dashed #6ee7b7;
  border-radius: 8px;
  background: var(--primary-bg, #ecfdf5);
}

.invoice-paste-panel__hd {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 8px;
  margin-bottom: 8px;
}

.invoice-paste-panel__title {
  font-weight: 600;
  color: var(--primary-dark, #059669);
}

.invoice-paste-panel__tip {
  font-size: 12px;
  color: var(--mint-text-muted, #64748b);
}

.invoice-paste-panel__actions {
  display: flex;
  justify-content: flex-end;
  gap: 8px;
  margin-top: 10px;
}
  • Step 4: 手工验收清单
  1. 打开 /#/crm/customer → 客户详情 → 开票信息 → 添加开票信息
  2. 粘贴安庆样例 → 识别并填入 → 六项主字段正确,备注含行号/邮编/邮寄
  3. 再粘贴江峰样例 → 识别并填入 → 字段被覆盖为江峰数据
  4. 「清空」只清粘贴框,表单不变
  5. 点确定仍走原有保存接口
  • Step 5: Commit(仅用户要求时) — 跳过默认

Task 4: 收尾

Files:

  • Optional sync: docs/demo-crm-invoice-paste-recognize.html(若 Demo 规则与实现不一致再改)

  • Spec 状态:将 docs/superpowers/specs/2026-08-20-crm-invoice-paste-recognize-design.md 顶部状态改为「已实现(待联调验收)」

  • Graphify: ./scripts/update-graph.sh frontend

  • Step 1: 更新 spec 状态行

  • Step 2: 跑单测再确认绿

cd web-admin && pnpm exec tsx --test src/utils/crm-invoice-paste-recognize.test.ts
  • Step 3: 更新前端图谱
./scripts/update-graph.sh frontend
  • Step 4: Commit(仅用户要求时) — 跳过默认

Spec coverage(自检)

Spec 要求 Task
方案 A 顶部粘贴区 Task 3
纯前端规则 Task 1–2
覆盖已识别字段 Task 3 handleRecognizePaste
空解析值不清空字段 Task 3 仅 if (v) 赋值
remark 追加、去重 Task 2 mergeInvoiceRemark
安庆/江峰样例 Task 1–2 单测
不改后端/API 无相关 Task

Placeholder scan

无 TBD / 「类似 Task N」占位。