This commit is contained in:
zlwu committed 2026-08-21 09:35:11 +08:00
1 parent b9ff9c9d67
commit 44749ae716
87 files changed
+35089 -21918

No files matched your search

@@ -0,0 +1,67 @@
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')
})
})
@@ -0,0 +1,167 @@
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
}
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
}
+81
View File
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
collectSelfVehicleRequiredCertErrors,
collectCarrierVehicleRequiredCertErrors,
ensureSelfVehicleCertSlots,
ensureCarrierVehicleCertSlots
} from './pl-cert-slots'
import {
isHuolalaTrailerNo,
shouldRequireVehicleTrailerCerts,
shouldShowVehicleTrailerCertSlots,
PL_VEHICLE_CERT_PART_TRAILER
} from './pl-vehicle-cert'
function completeSelfHeadCerts() {
return [
{ certName: '行驶证', expireDate: '2030-01-01', vehiclePart: 'head' },
{ certName: '营运证', expireDate: '2030-01-01', vehiclePart: 'head' },
{ certName: '保险单', expireDate: '2030-01-01', vehiclePart: 'head' }
]
}
describe('trailer cert rule helpers', () => {
it('detects 货拉拉 and gates trailer slot/require', () => {
assert.equal(isHuolalaTrailerNo('货拉拉'), true)
assert.equal(isHuolalaTrailerNo(' 货拉拉 '), true)
assert.equal(isHuolalaTrailerNo('皖LP359'), false)
assert.equal(shouldShowVehicleTrailerCertSlots('货拉拉'), false)
assert.equal(shouldShowVehicleTrailerCertSlots('皖LP359'), true)
assert.equal(shouldRequireVehicleTrailerCerts('self', '货拉拉'), false)
assert.equal(shouldRequireVehicleTrailerCerts('self', '皖LP359'), true)
assert.equal(shouldRequireVehicleTrailerCerts('carrier', '皖LP359'), false)
})
})
describe('ensure trailer cert slots', () => {
it('hides trailer slots for 货拉拉', () => {
const selfSlots = ensureSelfVehicleCertSlots([], '货拉拉')
assert.equal(
selfSlots.some(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER),
false
)
const carrierSlots = ensureCarrierVehicleCertSlots([], '货拉拉')
assert.equal(
carrierSlots.some(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER),
false
)
})
it('shows required trailer slots for self normal trailer', () => {
const slots = ensureSelfVehicleCertSlots([], '皖LP359')
const trailer = slots.filter(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER)
assert.ok(trailer.length > 0)
assert.ok(trailer.every(c => c._required === true))
})
it('shows optional trailer slots for carrier normal trailer', () => {
const slots = ensureCarrierVehicleCertSlots([], '皖LP359')
const trailer = slots.filter(c => c.vehiclePart === PL_VEHICLE_CERT_PART_TRAILER)
assert.ok(trailer.length > 0)
assert.ok(trailer.every(c => c._required === false))
})
})
describe('collect trailer cert errors', () => {
it('skips trailer validation for self 货拉拉', () => {
const errors = collectSelfVehicleRequiredCertErrors(completeSelfHeadCerts(), '货拉拉')
assert.deepEqual(errors, [])
})
it('requires trailer certs for self normal trailer', () => {
const errors = collectSelfVehicleRequiredCertErrors(completeSelfHeadCerts(), '皖LP359')
assert.ok(errors.some(e => e.includes('挂车')))
})
it('does not require trailer certs for carrier', () => {
const errors = collectCarrierVehicleRequiredCertErrors([], '皖LP359')
assert.deepEqual(errors, [])
})
})
+40 -15
View File
@@ -10,7 +10,9 @@ import {
type PlVehicleCertCheckType,
getVehicleHeadRequiredCertNames,
getVehicleTrailerRequiredCertNames,
normalizeVehicleCertPart
normalizeVehicleCertPart,
shouldRequireVehicleTrailerCerts,
shouldShowVehicleTrailerCertSlots
} from '@/utils/pl-vehicle-cert'
export type CertRowWithMeta = CertRow & { _key?: string; _required?: boolean }
@@ -75,6 +77,11 @@ export function collectVehicleRequiredCertErrors(
trailerNo: string | null | undefined,
vehicleType: PlVehicleCertCheckType
): string[] {
// 三方车辆证件不强制
if (vehicleType === 'carrier') {
return []
}
const headCerts = certList.filter(
c => normalizeVehicleCertPart(c.vehiclePart) === PL_VEHICLE_CERT_PART_HEAD
)
@@ -87,7 +94,7 @@ export function collectVehicleRequiredCertErrors(
getVehicleHeadRequiredCertNames(vehicleType),
'主车'
),
...(trailerNo?.trim()
...(shouldRequireVehicleTrailerCerts(vehicleType, trailerNo)
? collectRequiredCertSlotErrors(
trailerCerts,
getVehicleTrailerRequiredCertNames(vehicleType),
@@ -131,10 +138,11 @@ function findCertIndexForRequired(
export function mergeCertListWithRequiredSlots(
existing: CertRowWithMeta[],
requiredNames: readonly string[],
options?: { vehiclePart?: string; stripManagedNames?: readonly string[] }
options?: { vehiclePart?: string; stripManagedNames?: readonly string[]; markRequired?: boolean }
): CertRowWithMeta[] {
const vehiclePart = options?.vehiclePart
const stripManagedNames = options?.stripManagedNames
const markRequired = options?.markRequired !== false
const normalizedPart = vehiclePart ? normalizeVehicleCertPart(vehiclePart) : null
let source = existing
@@ -167,13 +175,13 @@ export function mergeCertListWithRequiredSlots(
...next[idx],
certName: requiredName,
vehiclePart: normalizedPart || next[idx].vehiclePart || PL_VEHICLE_CERT_PART_HEAD,
_required: true
_required: markRequired
}
}
return createEmptyCertRow(
requiredName,
normalizedPart || PL_VEHICLE_CERT_PART_HEAD,
true
markRequired
)
})
@@ -187,18 +195,26 @@ export function mergeCertListWithRequiredSlots(
function ensureVehicleCertSlotsByNames(
certList: CertRowWithMeta[],
trailerNo: string | null | undefined,
headRequired: readonly string[],
trailerRequired: readonly string[]
headNames: readonly string[],
trailerNames: readonly string[],
options?: { headRequired?: boolean; trailerRequired?: boolean }
): CertRowWithMeta[] {
let merged = mergeCertListWithRequiredSlots(certList, headRequired, {
vehiclePart: PL_VEHICLE_CERT_PART_HEAD
const headRequired = options?.headRequired !== false
const trailerRequired = Boolean(options?.trailerRequired)
let merged = mergeCertListWithRequiredSlots(certList, headNames, {
vehiclePart: PL_VEHICLE_CERT_PART_HEAD,
markRequired: headRequired
})
if (trailerNo?.trim()) {
merged = mergeCertListWithRequiredSlots(merged, trailerRequired, {
vehiclePart: PL_VEHICLE_CERT_PART_TRAILER
if (shouldShowVehicleTrailerCertSlots(trailerNo)) {
merged = mergeCertListWithRequiredSlots(merged, trailerNames, {
vehiclePart: PL_VEHICLE_CERT_PART_TRAILER,
markRequired: trailerRequired
})
} else {
merged = stripTrailerRequiredSlots(merged, trailerRequired)
// 无挂车号或货拉拉:移除全部挂车侧证书行
merged = merged.filter(
cert => normalizeVehicleCertPart(cert.vehiclePart) !== PL_VEHICLE_CERT_PART_TRAILER
)
}
return merged
}
@@ -211,7 +227,11 @@ export function ensureSelfVehicleCertSlots(
certList,
trailerNo,
PL_SELF_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
PL_SELF_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
{
headRequired: true,
trailerRequired: shouldRequireVehicleTrailerCerts('self', trailerNo)
}
)
}
@@ -223,7 +243,12 @@ export function ensureCarrierVehicleCertSlots(
certList,
trailerNo,
PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES,
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES
PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES,
{
// 三方主车/挂车证件均展示槽位但不强制
headRequired: false,
trailerRequired: false
}
)
}
@@ -0,0 +1,45 @@
import { isPlInternalCustomerName } from '@/utils/pl-internal-customer'
export type PlanContractDisplayLine = {
side: string
no: string
pending?: boolean
}
type PlanContractLike = {
contractId?: string
contractNo?: string
}
type PlanContractParties = {
supplierName?: string | null
customerName?: string | null
supplierContracts?: PlanContractLike[]
demanderContracts?: PlanContractLike[]
}
/** 单侧:有编号显示编号;非内置缺合同「待补充」;内置缺合同「—」 */
export function planContractSideLines(
side: '供' | '需',
contracts: PlanContractLike[] | undefined,
customerName?: string | null
): PlanContractDisplayLine[] {
const lines: PlanContractDisplayLine[] = []
for (const item of contracts || []) {
const no = item.contractNo || item.contractId
if (no) lines.push({ side, no: String(no) })
}
if (lines.length) return lines
if (isPlInternalCustomerName(customerName)) {
return [{ side, no: '—' }]
}
return [{ side, no: '待补充', pending: true }]
}
/** 合同列:供/需各至少一行 */
export function planContractLines(plan: PlanContractParties): PlanContractDisplayLine[] {
return [
...planContractSideLines('供', plan.supplierContracts, plan.supplierName),
...planContractSideLines('需', plan.demanderContracts, plan.customerName)
]
}
+27 -2
View File
@@ -25,8 +25,29 @@ export const PL_CARRIER_VEHICLE_HEAD_REQUIRED_CERT_NAMES = ['行驶证', '营运
export const PL_CARRIER_VEHICLE_TRAILER_REQUIRED_CERT_NAMES = ['行驶证', '营运证'] as const
/** 特殊挂车号:不展示挂车证件槽位、不校验挂车证件 */
export const PL_HUOLALA_TRAILER_NO = '货拉拉'
export type PlVehicleCertCheckType = 'self' | 'carrier'
export function isHuolalaTrailerNo(trailerNo?: string | null): boolean {
return (trailerNo || '').trim() === PL_HUOLALA_TRAILER_NO
}
/** 有挂车号且非货拉拉时展示挂车证件区 */
export function shouldShowVehicleTrailerCertSlots(trailerNo?: string | null): boolean {
const text = (trailerNo || '').trim()
return Boolean(text) && !isHuolalaTrailerNo(text)
}
/** 仅山峡自有 + 正常挂车号时强制挂车证件 */
export function shouldRequireVehicleTrailerCerts(
vehicleType: PlVehicleCertCheckType,
trailerNo?: string | null
): boolean {
return vehicleType === 'self' && shouldShowVehicleTrailerCertSlots(trailerNo)
}
export const PL_VEHICLE_CERT_NAME_OPTIONS = [
'行驶证',
'营运证',
@@ -126,7 +147,11 @@ export function hasVehicleCertMissing(options: {
}): boolean {
const { vehicleType, certList = [], trailerNo } = options
const { headCerts, trailerCerts } = splitVehicleCertsByPart(certList)
const hasTrailer = Boolean(trailerNo?.trim())
// 三方车辆证件不强制,列表缺失后缀仅对自有生效
if (vehicleType === 'carrier') {
return false
}
if (
getMissingVehiclePartCertNames(headCerts, PL_VEHICLE_CERT_PART_HEAD, vehicleType).length > 0
@@ -135,7 +160,7 @@ export function hasVehicleCertMissing(options: {
}
if (
hasTrailer &&
shouldRequireVehicleTrailerCerts(vehicleType, trailerNo) &&
getMissingVehiclePartCertNames(trailerCerts, PL_VEHICLE_CERT_PART_TRAILER, vehicleType)
.length > 0
) {