feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强

汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-10 21:20:37 +08:00
1 parent 2d3472f5c4
commit db31990ebe
235 files changed
+14898 -10196

No files matched your search

+2 -5
View File
@@ -5,8 +5,8 @@ export function looksLikeDateTime(value: unknown): boolean {
}
/**
* 展示用日期时间:若时分秒均为 0(或无时间部分)则只显示 YYYY-MM-DD;
* 否则保留有意义的时间部分。
* 展示用日期时间:若时分均为 0(或无时间部分)则只显示 YYYY-MM-DD;
* 否则显示到时分(不含秒)。
*/
export function formatDateTimeDisplay(value: string | number | null | undefined): string {
const s = String(value ?? '').trim()
@@ -22,9 +22,6 @@ export function formatDateTimeDisplay(value: string | number | null | undefined)
if (hh === '00' && mm === '00' && ss.startsWith('00')) {
return date
}
if (ss && ss !== '00') {
return `${date} ${hh}:${mm}:${ss.slice(0, 2)}`
}
return `${date} ${hh}:${mm}`
}
@@ -1,350 +0,0 @@
import { parseChineseDatetimeText } from '@/utils/chinese-datetime-parse'
import { parseBirthOrder } from '@/utils/chinese-numeral-parse'
import { extractNumericPart } from '@/utils/numeric-input-parse'
import {
SAMPLE_TYPE_PERINATAL_HSC,
SAMPLE_TYPE_UC_MSC
} from '@/utils/sample-production-reception-fields'
export type DonationSampleImportHint = {
typeKey: 'hsc' | 'uc_msc'
typeName: string
collectTime: string
}
export type DonationStandardImportParsed = {
sampleTypeHeader: string
sampleHints: DonationSampleImportHint[]
motherName?: string
newbornSex?: '1' | '0'
newbornWeightGram?: number | null
apgarScore?: number | null
newbornDeliveryTime?: string
/** 协议级样本采集时间:多样本时为各 hint 最早时间 */
sampleCollectTime?: string
maternalBloodCollectTime?: string
maternalBloodExplicitEmpty?: boolean
deliveryMode?: string
birthOrder?: number | null
medicalStaff?: string
hospitalName?: string
/** @deprecated 使用 transportModeText */
transportExpressName?: string
transportModeText?: string
transportType?: 'kdys' | 'ryxd'
salesUserName?: string
}
type CommonFieldKey = keyof Omit<
DonationStandardImportParsed,
'sampleTypeHeader' | 'sampleHints' | 'transportExpressName' | 'transportModeText' | 'transportType'
>
const KEY_ALIASES: Record<string, CommonFieldKey> = {
产妇姓名: 'motherName',
母亲姓名: 'motherName',
新生儿性别: 'newbornSex',
新生儿体重: 'newbornWeightGram',
'新生儿体重(克)': 'newbornWeightGram',
新生儿评分: 'apgarScore',
'新生儿评分1分钟': 'apgarScore',
'新生儿评分5分钟': 'apgarScore',
分娩时间: 'newbornDeliveryTime',
新生儿分娩时间: 'newbornDeliveryTime',
母血采集时间: 'maternalBloodCollectTime',
分娩方式: 'deliveryMode',
胎次: 'birthOrder',
采集医护: 'medicalStaff',
采集医院: 'hospitalName',
运输方式: 'transportModeText',
销售人员: 'salesUserName',
采集专员: 'salesUserName'
}
const MATERNAL_BLOOD_EMPTY_MARKERS = ['未采集', '无', '暂无', '—', '-', '未采']
function normalizeLine(line: string): string {
return line
.replace(/^[\s【】]+|[\s【】]+$/g, '')
.trim()
}
function splitKeyValue(line: string): { key: string; value: string } | null {
const match = line.match(/^([^::]+)[::]\s*(.*)$/)
if (!match) return null
return { key: match[1].trim(), value: match[2].trim() }
}
function resolveSampleCollectHint(key: string): Omit<DonationSampleImportHint, 'collectTime'> | null {
const k = key.trim()
if (/脐带采集时间/.test(k)) {
return { typeKey: 'uc_msc', typeName: SAMPLE_TYPE_UC_MSC }
}
if (/造血干细胞.*采集时间/.test(k) || k === '样本采集时间') {
return { typeKey: 'hsc', typeName: SAMPLE_TYPE_PERINATAL_HSC }
}
return null
}
function parseSex(value: string): '1' | '0' | undefined {
const v = value.trim()
if (!v) return undefined
if (/^男|^男孩/.test(v)) return '1'
if (/^女|^女孩/.test(v)) return '0'
return undefined
}
function parseDeliveryMode(value: string): string | undefined {
const v = value.trim()
if (!v) return undefined
if (/剖宫产|剖腹产|刨宫产/.test(v)) return 'pgc'
if (/顺产|自然分娩/.test(v)) return 'sc'
return undefined
}
function parseWeightGram(value: string): number | null | undefined {
const v = value.trim()
if (!v) return undefined
const num = extractNumericPart(v)
if (num === '' || num == null) return undefined
const n = Number(num)
return Number.isFinite(n) ? n : undefined
}
function parseScore(value: string): number | null | undefined {
const v = value.trim()
if (!v) return undefined
const num = extractNumericPart(v)
if (num === '' || num == null) return undefined
const n = Number(num)
return Number.isFinite(n) ? n : undefined
}
function parseDatetime(value: string, minutePrecision = false): string | undefined {
const v = value.trim()
if (!v) return undefined
if (MATERNAL_BLOOD_EMPTY_MARKERS.some((m) => v === m || v.includes(m))) {
return undefined
}
return (
parseChineseDatetimeText(v, minutePrecision ? 'datetime-minute' : 'datetime') || undefined
)
}
function isMaternalBloodEmpty(value: string): boolean {
const v = value.trim()
if (!v) return true
return MATERNAL_BLOOD_EMPTY_MARKERS.some((m) => v === m || v.includes(m))
}
/** 运输方式文本 → 系统 transportType */
export function resolveTransportType(text: string): 'kdys' | 'ryxd' | null {
const v = (text || '').trim()
if (!v) return null
// 「顺丰车」为「顺风车」笔误,须先于「顺丰」快递匹配
if (/顺风车|顺丰车|自行携带|人员携带|自带|自送|亲自送|自驾/.test(v)) return 'ryxd'
if (/帮助|取样本|送到实验室|专人送|送到/.test(v)) return 'ryxd'
if (/快递|顺丰|圆通|中通|韵达|申通|EMS|邮政|极兔|京东|德邦/.test(v)) return 'kdys'
return null
}
function addSampleHint(
hints: DonationSampleImportHint[],
meta: Omit<DonationSampleImportHint, 'collectTime'>,
collectTime: string
) {
if (hints.some((h) => h.typeKey === meta.typeKey)) return
hints.push({ ...meta, collectTime })
}
function earliestCollectTime(hints: DonationSampleImportHint[]): string | undefined {
if (!hints.length) return undefined
return hints
.map((h) => h.collectTime)
.sort((a, b) => a.localeCompare(b))[0]
}
/** 解析捐赠协议单一键导入标准文本 */
export function parseDonationStandardImportText(text: string): DonationStandardImportParsed | null {
const raw = (text || '').trim()
if (!raw) return null
const lines = raw
.split(/\r?\n/)
.map(normalizeLine)
.filter(Boolean)
if (!lines.length) return null
const result: DonationStandardImportParsed = {
sampleTypeHeader: lines[0],
sampleHints: []
}
for (let i = 1; i < lines.length; i++) {
const kv = splitKeyValue(lines[i])
if (!kv) continue
const sampleMeta = resolveSampleCollectHint(kv.key)
if (sampleMeta) {
const dt = parseDatetime(kv.value, true)
if (dt) addSampleHint(result.sampleHints, sampleMeta, dt)
continue
}
const field = KEY_ALIASES[kv.key]
if (!field) continue
switch (field) {
case 'motherName':
result.motherName = kv.value
break
case 'newbornSex': {
const sex = parseSex(kv.value)
if (sex) result.newbornSex = sex
break
}
case 'newbornWeightGram': {
const w = parseWeightGram(kv.value)
if (w !== undefined) result.newbornWeightGram = w
break
}
case 'apgarScore': {
const s = parseScore(kv.value)
if (s !== undefined) result.apgarScore = s
break
}
case 'newbornDeliveryTime': {
const dt = parseDatetime(kv.value)
if (dt) result.newbornDeliveryTime = dt
break
}
case 'maternalBloodCollectTime':
if (isMaternalBloodEmpty(kv.value)) {
result.maternalBloodExplicitEmpty = true
} else {
const dt = parseDatetime(kv.value, true)
if (dt) result.maternalBloodCollectTime = dt
}
break
case 'deliveryMode': {
const mode = parseDeliveryMode(kv.value)
if (mode) result.deliveryMode = mode
break
}
case 'birthOrder': {
const n = parseBirthOrder(kv.value)
if (n !== undefined) result.birthOrder = n
break
}
case 'medicalStaff':
result.medicalStaff = kv.value
break
case 'hospitalName':
if (!result.hospitalName) result.hospitalName = kv.value
break
case 'transportModeText': {
result.transportModeText = kv.value
result.transportExpressName = kv.value
result.transportType = resolveTransportType(kv.value) ?? undefined
break
}
case 'salesUserName':
result.salesUserName = kv.value
break
default:
break
}
}
if (result.sampleHints.length) {
result.sampleCollectTime = earliestCollectTime(result.sampleHints)
}
return result
}
type CellSampleTypeOption = { id: string; typeName: string; storageYears?: number | null }
const SAMPLE_TYPE_SEGMENTS = ['围产期', '造血', '干细胞', '脐带', '间充质', '胎盘', '亚全能', '捐献']
/** 按类型名称精确或包含匹配 */
export function matchSampleTypeByExactName(
typeName: string,
types: CellSampleTypeOption[]
): CellSampleTypeOption | null {
const target = (typeName || '').trim()
if (!target || !types.length) return null
const exact = types.find((t) => t.typeName === target)
if (exact) return exact
const contains = types.filter(
(t) => t.typeName.includes(target) || target.includes(t.typeName)
)
if (contains.length === 1) return contains[0]
return matchCellSampleType(target, types)
}
/** 根据首行文本与细胞样本类型列表做语义匹配 */
export function matchCellSampleType(
headerLine: string,
types: CellSampleTypeOption[]
): CellSampleTypeOption | null {
const header = normalizeLine(headerLine)
if (!header || !types.length) return null
let best: CellSampleTypeOption | null = null
let bestScore = 0
for (const t of types) {
const name = t.typeName || ''
let score = 0
for (const seg of SAMPLE_TYPE_SEGMENTS) {
if (header.includes(seg) && name.includes(seg)) score += 2
}
if (header.includes('围产期') && name.includes('围产期')) score += 3
if (header.includes('捐献') && name.includes('造血')) score += 2
if (header.includes('间充质') && name.includes('间充质')) score += 3
if (header.includes('胎盘') && name.includes('胎盘')) score += 3
if (header.includes('亚全能') && name.includes('亚全能')) score += 2
if (score > bestScore) {
bestScore = score
best = t
}
}
return bestScore > 0 ? best : null
}
/** 在快递公司列表中匹配名称(优先完全匹配,否则包含匹配) */
export function matchExpressCompany(
name: string,
companies: Array<{ id: string; expressName: string }>
): { id: string; expressName: string } | null {
const target = (name || '').trim()
if (!target || !companies.length) return null
const exact = companies.find((c) => c.expressName === target)
if (exact) return exact
const normalized = target.replace(/快递$/, '')
const contains = companies.filter(
(c) => c.expressName.includes(target) || c.expressName.includes(normalized)
)
if (contains.length === 1) return contains[0]
const sf = companies.find((c) => /顺丰/.test(c.expressName) && /顺丰/.test(target))
if (sf) return sf
return contains[0] || null
}
export function isBlankString(val: unknown): boolean {
return val == null || String(val).trim() === ''
}
export function isEmptyNumber(val: unknown): boolean {
return val == null || val === ''
}
@@ -59,6 +59,17 @@ describe('flattenProtocolAuditRows', () => {
assert.equal(rows.length, 1)
assert.equal(rows[0].isCoStored, false)
})
it('donationNo maps to protocolNo', () => {
const rows = flattenProtocolAuditRows([
{
id: 'd1',
donationNo: 'JZ-001',
sampleItems: [{ sampleTypeName: '脐带血', sampleCode: 'D1', labStorageCode: 'B9' }]
}
])
assert.equal(rows[0].protocolNo, 'JZ-001')
})
})
describe('uniqueProtocolsFromFlatRows', () => {
@@ -80,19 +91,41 @@ describe('uniqueProtocolsFromFlatRows', () => {
})
describe('firstPayTimeLines', () => {
it('prefers transferTime then dueDate; stacks multiple', () => {
it('prefers firstPayTransferTime from summary', () => {
const lines = firstPayTimeLines({
firstPayTransferTime: '2025-09-20 14:30:00',
paymentDetails: [
{ installmentNo: 1, transferTime: '2025-09-15 09:20:00', installmentDueDate: '2025-09-01' },
{ installmentNo: 1, transferTime: '2025-09-15 09:20:00' },
{ installmentNo: 2, transferTime: '2025-10-15 10:05:00' }
]
})
assert.deepEqual(lines, ['2025-09-15 09:20:00', '2025-10-15 10:05:00'])
assert.deepEqual(lines, ['2025-09-20 14:30:00'])
})
it('falls back to dueDate when no transferTime', () => {
it('only first period; ignores later installments', () => {
const lines = firstPayTimeLines({
paymentDetails: [
{ installmentNo: 1, transferTime: '2025-09-15 09:20:00', installmentDueDate: '2025-09-01' },
{ installmentNo: 2, transferTime: '2025-10-15 10:05:00' },
{ installmentNo: 3, transferTime: '2025-11-15 11:00:00' }
]
})
assert.deepEqual(lines, ['2025-09-15 09:20:00'])
})
it('among period-1 transfers picks the latest transferTime', () => {
const lines = firstPayTimeLines({
paymentDetails: [
{ installmentNo: 1, transferTime: '2025-09-15 09:20:00' },
{ installmentNo: 1, transferTime: '2025-09-20 14:30:00' },
{ installmentNo: 1, transferTime: '2025-09-18 08:00:00' },
{ installmentNo: 2, transferTime: '2025-10-15 10:05:00' }
]
})
assert.deepEqual(lines, ['2025-09-20 14:30:00'])
})
it('empty when period 1 has no transferTime', () => {
const lines = firstPayTimeLines({
paymentDetails: [{ installmentNo: 1, installmentDueDate: '2025-09-01' }]
})
assert.deepEqual(lines, ['2025-09-01'])
assert.deepEqual(lines, [])
})
})
+37 -19
View File
@@ -1,6 +1,8 @@
import type { CollectionProtocolRow } from '../api/collection-protocol'
import type { QcProtocolPaymentSummary } from '../api/qc-work-order'
/** 细胞存储协议 / 科研捐赠列表行(均为 Record) */
export type ProtocolAuditSourceRow = Record<string, unknown>
export type ProtocolAuditSampleTransport = {
transportType?: string
destSiteName?: string
@@ -14,13 +16,14 @@ export type ProtocolAuditSampleTransport = {
export type ProtocolAuditFlatRow = {
rowKey: string
protocolId: string
protocol: CollectionProtocolRow
protocol: ProtocolAuditSourceRow
sampleCount: number
sampleIndex: number
isCoStored: boolean
collectDate: string
sampleCollectTime: string
motherName: string
/** 协议编号或捐赠单号 */
protocolNo: string
regionName: string
hospitalName: string
@@ -49,7 +52,7 @@ export function extractCollectDate(raw?: string | null): string {
return m ? m[1] : ''
}
function readSampleItems(protocol: CollectionProtocolRow): Record<string, unknown>[] {
function readSampleItems(protocol: ProtocolAuditSourceRow): Record<string, unknown>[] {
const raw = protocol.sampleItems
if (Array.isArray(raw) && raw.length) {
return raw.map((x) => asRecord(x) || {})
@@ -58,7 +61,7 @@ function readSampleItems(protocol: CollectionProtocolRow): Record<string, unknow
}
export function flattenProtocolAuditRows(
list: CollectionProtocolRow[]
list: ProtocolAuditSourceRow[]
): ProtocolAuditFlatRow[] {
const out: ProtocolAuditFlatRow[] = []
for (const protocol of list || []) {
@@ -81,7 +84,7 @@ export function flattenProtocolAuditRows(
collectDate: extractCollectDate(str(protocol.sampleCollectTime)),
sampleCollectTime: str(protocol.sampleCollectTime),
motherName: str(protocol.motherName),
protocolNo: str(protocol.protocolNo),
protocolNo: str(protocol.protocolNo) || str(protocol.donationNo),
regionName: str(protocol.regionName),
hospitalName: str(protocol.hospitalName),
salesUserName: str(protocol.salesUserName),
@@ -103,9 +106,9 @@ export function flattenProtocolAuditRows(
export function uniqueProtocolsFromFlatRows(
rows: ProtocolAuditFlatRow[]
): CollectionProtocolRow[] {
): ProtocolAuditSourceRow[] {
const seen = new Set<string>()
const out: CollectionProtocolRow[] = []
const out: ProtocolAuditSourceRow[] = []
for (const r of rows) {
if (!r.protocolId || seen.has(r.protocolId)) continue
seen.add(r.protocolId)
@@ -114,22 +117,37 @@ export function uniqueProtocolsFromFlatRows(
return out
}
/**
* 首期缴费时间:优先用后端 firstPayTransferTime(第 1 期转账最后一笔);
* 否则从 paymentDetails 中取第 1 期 transferTime 最晚的一笔。
* 无转账时间时返回空(不回退应缴日)。
*/
export function firstPayTimeLines(
summary: QcProtocolPaymentSummary | null | undefined
): string[] {
const preferred = str(summary?.firstPayTransferTime)
if (preferred) return [preferred]
const details = summary?.paymentDetails
if (!Array.isArray(details) || !details.length) return []
const sorted = [...details].sort((a, b) => {
const na = a.installmentNo == null ? 10_000 : Number(a.installmentNo)
const nb = b.installmentNo == null ? 10_000 : Number(b.installmentNo)
return na - nb
})
const lines: string[] = []
for (const d of sorted) {
const t = str((d as { transferTime?: string }).transferTime)
const due = str(d.installmentDueDate)
const pick = t || due
if (pick) lines.push(pick)
const numbered = details.filter(
(d) => d.installmentNo != null && String(d.installmentNo).trim() !== ''
)
let periodRows = details
if (numbered.some((d) => Number(d.installmentNo) === 1)) {
periodRows = details.filter((d) => Number(d.installmentNo) === 1)
} else if (numbered.length) {
const minNo = Math.min(...numbered.map((d) => Number(d.installmentNo)))
periodRows = details.filter((d) => Number(d.installmentNo) === minNo)
} else {
periodRows = [details[0]]
}
return lines
const times = periodRows
.map((d) => str(d.transferTime))
.filter(Boolean)
.sort()
if (!times.length) return []
return [times[times.length - 1]]
}
@@ -125,7 +125,10 @@ export function buildProtocolPaymentFlowSteps(
time: receiptSubmitTime(d),
fields: [
{ label: '转账方式', value: transferTypeLabel(String(d.transferType || '')) || '—' },
{ label: '实缴金额(元)', value: formatAmount(d.transferAmount) },
{
label: d.source === 'presign' ? '缴费金额(元)' : '实缴金额(元)',
value: formatAmount(d.transferAmount)
},
{ label: '转账时间', value: dash(d.transferTime) },
{ label: '备注', value: dash(d.remark), multiline: true },
{
@@ -1,55 +1,23 @@
/** 采集协议单 · 缴费默认定价表(样本数 × 缴费模式) */
/** 采集协议单 · 缴费模式工具(已废除硬编码价目表) */
export type PayMode = 'dj' | 'fq' | 'djfq'
export type PaymentPreset = {
contractAmount: number
installmentCount?: number
installmentAmounts?: number[]
}
const PRESETS: Record<1 | 2, Partial<Record<PayMode, PaymentPreset>>> = {
1: {
dj: { contractAmount: 22880 },
fq: {
contractAmount: 25880,
installmentCount: 6,
installmentAmounts: [6480, 3880, 3880, 3880, 3880, 3880]
}
},
2: {
dj: { contractAmount: 37880 },
djfq: {
contractAmount: 41000,
installmentCount: 4,
installmentAmounts: [29360, 3880, 3880, 3880]
},
fq: {
contractAmount: 44000,
installmentCount: 5,
installmentAmounts: [12960, 7760, 7760, 7760, 7760]
}
}
}
export type PayMode = 'dj' | 'fq' | 'djfq' | 'fgd'
export function getActiveSampleCount(samples: Array<{ sampleStatus?: string }>): 1 | 2 {
const n = samples.filter((s) => String(s.sampleStatus || '') !== 'yzf').length
return (n >= 2 ? 2 : 1) as 1 | 2
}
export function resolvePaymentPreset(
sampleCount: 1 | 2,
payMode: PayMode
): PaymentPreset | null {
return PRESETS[sampleCount]?.[payMode] ?? null
}
export function isInstallmentPayMode(payMode: string): boolean {
return payMode === 'fq' || payMode === 'djfq'
}
/** 需要配置多期 schedule 的模式(分期 / 趸交+分期 / 分改趸) */
export function isMultiSchedulePayMode(payMode: string): boolean {
return payMode === 'fq' || payMode === 'djfq' || payMode === 'fgd'
}
export function availablePayModes(sampleCount: 1 | 2): PayMode[] {
return sampleCount === 2 ? ['dj', 'djfq', 'fq'] : ['dj', 'fq']
return sampleCount === 2 ? ['dj', 'djfq', 'fq', 'fgd'] : ['dj', 'fq', 'fgd']
}
/** 校验缴费模式与样本数是否匹配(如 djfq 仅双样本) */
@@ -0,0 +1,333 @@
// web-admin/src/utils/protocol-sample-payment.test.ts
import { describe, it } from 'node:test'
import assert from 'node:assert/strict'
import {
parsePreferentialPrice,
resolveSampleContractAmount,
resolvePerSamplePaymentDefaults,
shouldReplaceContractWithPreferentialPrice,
sampleScheduleRequiredFieldsHint,
sampleScheduleDuesMismatchHint,
sumSampleContractAmounts,
schedulePaidArrears,
planFqToFgdMerge,
applyFqToFgdMergeToPayment,
applyConsolidateRemainingToPayment,
applyExpandRemainingToInstallment,
availableSamplePayModes,
samplePaymentHasConfirmedTransfer
} from './protocol-sample-payment'
describe('parsePreferentialPrice', () => {
it('reads sample.price as preferential amount', () => {
assert.equal(parsePreferentialPrice({ price: 20000 }), 20000)
assert.equal(parsePreferentialPrice({ price: '22000' }), 22000)
})
it('null/empty → null', () => {
assert.equal(parsePreferentialPrice({ price: null }), null)
assert.equal(parsePreferentialPrice({ price: '' }), null)
assert.equal(parsePreferentialPrice({}), null)
assert.equal(parsePreferentialPrice(null), null)
})
})
describe('resolveSampleContractAmount', () => {
it('uses preferential price when set', () => {
assert.equal(resolveSampleContractAmount({ price: 20000 }), 20000)
assert.equal(resolveSampleContractAmount({ price: 22000 }), 22000)
})
it('no fallback to hardcoded price table', () => {
assert.equal(resolveSampleContractAmount({}), null)
assert.equal(resolveSampleContractAmount({ price: null }), null)
})
})
describe('resolvePerSamplePaymentDefaults', () => {
it('dj defaults contract to preferential price', () => {
const d = resolvePerSamplePaymentDefaults({ price: 20000 }, 'dj')
assert.equal(d.contractAmount, 20000)
assert.equal(d.installmentCount, undefined)
})
it('fq has structural count but empty installment amounts', () => {
const d = resolvePerSamplePaymentDefaults({ price: 20000 }, 'fq')
assert.equal(d.contractAmount, 20000)
assert.equal(d.installmentCount, 6)
assert.deepEqual(d.installmentAmounts, [])
})
})
describe('shouldReplaceContractWithPreferentialPrice', () => {
it('only replaces null/empty contract', () => {
assert.equal(shouldReplaceContractWithPreferentialPrice(null, 20000), true)
assert.equal(shouldReplaceContractWithPreferentialPrice(22880, 20000), false)
assert.equal(shouldReplaceContractWithPreferentialPrice(18000, 20000), false)
})
})
describe('sampleScheduleRequiredFieldsHint', () => {
it('requires amount and dueDate for each fq period', () => {
const hint = sampleScheduleRequiredFieldsHint({
sampleCode: 'A1',
payMode: 'fq',
contractAmount: 20000,
schedules: [
{ dueAmount: null, dueDate: '20261001', scheduleStatus: 'open' },
{ dueAmount: 10000, dueDate: '', scheduleStatus: 'open' }
]
})
assert.ok(hint)
assert.match(hint!, /应缴金额/)
})
it('ok when all periods filled', () => {
assert.equal(
sampleScheduleRequiredFieldsHint({
payMode: 'fq',
contractAmount: 20000,
schedules: [
{ dueAmount: 10000, dueDate: '20261001' },
{ dueAmount: 10000, dueDate: '20261101' }
]
}),
null
)
})
})
describe('sampleScheduleDuesMismatchHint', () => {
it('sum must equal contract', () => {
const hint = sampleScheduleDuesMismatchHint({
payMode: 'fq',
contractAmount: 20000,
schedules: [
{ dueAmount: 10000, dueDate: '20261001' },
{ dueAmount: 5000, dueDate: '20261101' }
]
})
assert.ok(hint)
})
})
describe('sumSampleContractAmounts', () => {
it('two samples 20000+22000 → 42000', () => {
const payments = [
{ contractAmount: resolveSampleContractAmount({ price: 20000 }) },
{ contractAmount: resolveSampleContractAmount({ price: 22000 }) }
]
assert.equal(sumSampleContractAmounts(payments), 42000)
})
})
describe('schedulePaidArrears', () => {
it('display paid includes dfj + jfqrz + yjf', () => {
const { paid, arrears } = schedulePaidArrears(25880, [
{ payStatus: 'dfj', transferAmount: 480 },
{ payStatus: 'dfj', transferAmount: 6000 },
{ payStatus: 'jfqrz', transferAmount: 100 },
{ payStatus: 'yjf', transferAmount: 200 },
{ payStatus: 'ytf', transferAmount: 50 }
])
assert.equal(paid, 6780)
assert.equal(arrears, 19100)
})
})
describe('planFqToFgdMerge', () => {
it('keeps paid periods and merges remaining into first unpaid', () => {
const schedules = [
{
id: 's1',
scheduleNo: 1,
dueAmount: 5000,
scheduleStatus: 'cleared',
transfers: [{ payStatus: 'yjf', transferAmount: 5000 }]
},
{
id: 's2',
scheduleNo: 2,
dueAmount: 5000,
scheduleStatus: 'cleared',
transfers: [{ payStatus: 'yjf', transferAmount: 5000 }]
},
{ id: 's3', scheduleNo: 3, dueAmount: 5000, scheduleStatus: 'open', transfers: [] },
{ id: 's4', scheduleNo: 4, dueAmount: 5000, scheduleStatus: 'open', transfers: [] },
{ id: 's5', scheduleNo: 5, dueAmount: 5000, scheduleStatus: 'open', transfers: [] },
{ id: 's6', scheduleNo: 6, dueAmount: 4800, scheduleStatus: 'open', transfers: [] }
]
const plan = planFqToFgdMerge(schedules)
assert.equal(plan.targetScheduleId, 's3')
assert.equal(plan.targetScheduleNo, 3)
assert.equal(plan.newDueAmount, 19800)
assert.deepEqual(plan.softDeleteScheduleIds, ['s4', 's5', 's6'])
assert.equal(plan.newInstallmentCount, 3)
})
it('rejects when a later period already has confirmed transfer', () => {
assert.throws(
() =>
planFqToFgdMerge([
{ id: 's1', scheduleNo: 1, dueAmount: 1000, transfers: [] },
{
id: 's2',
scheduleNo: 2,
dueAmount: 1000,
transfers: [{ payStatus: 'yjf', transferAmount: 1000 }]
}
]),
/后续期/
)
})
})
describe('applyFqToFgdMergeToPayment', () => {
it('updates payMode/count and drops later unpaid schedules', () => {
const next = applyFqToFgdMergeToPayment({
payMode: 'fq',
installmentCount: 6,
contractAmount: 29800,
schedules: [
{
id: 's1',
scheduleNo: 1,
dueAmount: 5000,
scheduleStatus: 'cleared',
transfers: [{ payStatus: 'yjf', transferAmount: 5000 }]
},
{
id: 's2',
scheduleNo: 2,
dueAmount: 5000,
scheduleStatus: 'cleared',
transfers: [{ payStatus: 'yjf', transferAmount: 5000 }]
},
{ id: 's3', scheduleNo: 3, dueAmount: 5000, scheduleStatus: 'open', transfers: [] },
{ id: 's4', scheduleNo: 4, dueAmount: 5000, scheduleStatus: 'open', transfers: [] },
{ id: 's5', scheduleNo: 5, dueAmount: 5000, scheduleStatus: 'open', transfers: [] },
{ id: 's6', scheduleNo: 6, dueAmount: 4800, scheduleStatus: 'open', transfers: [] }
]
})
assert.equal(next.payMode, 'fgd')
assert.equal(next.installmentCount, 3)
assert.equal(next.schedules.length, 3)
assert.equal(next.schedules[2].dueAmount, 19800)
assert.deepEqual(
next.schedules.map((s: { scheduleNo: number }) => s.scheduleNo),
[1, 2, 3]
)
})
})
describe('availableSamplePayModes', () => {
it('fq → dj/fgd allowed', () => {
assert.deepEqual(availableSamplePayModes({ payMode: 'fq' }), ['fq', 'dj', 'fgd'])
})
it('dj cannot switch to fgd', () => {
assert.deepEqual(availableSamplePayModes({ payMode: 'dj' }), ['dj', 'fq'])
})
it('fgd cannot switch to dj', () => {
assert.deepEqual(availableSamplePayModes({ payMode: 'fgd' }), ['fgd', 'fq'])
})
})
describe('applyConsolidateRemainingToPayment', () => {
it('fq→dj keeps paid and consolidates remaining', () => {
const next = applyConsolidateRemainingToPayment(
{
payMode: 'fq',
installmentCount: 6,
contractAmount: 29800,
schedules: [
{
id: 's1',
scheduleNo: 1,
dueAmount: 5000,
transfers: [{ payStatus: 'yjf', transferAmount: 5000 }]
},
{
id: 's2',
scheduleNo: 2,
dueAmount: 5000,
transfers: [{ payStatus: 'yjf', transferAmount: 5000 }]
},
{ id: 's3', scheduleNo: 3, dueAmount: 5000, transfers: [] },
{ id: 's4', scheduleNo: 4, dueAmount: 5000, transfers: [] },
{ id: 's5', scheduleNo: 5, dueAmount: 5000, transfers: [] },
{ id: 's6', scheduleNo: 6, dueAmount: 4800, transfers: [] }
]
},
'dj'
)
assert.equal(next.payMode, 'dj')
assert.equal(next.installmentCount, 3)
assert.equal(next.schedules.length, 3)
assert.equal(next.schedules[2].dueAmount, 19800)
})
})
describe('applyExpandRemainingToInstallment', () => {
it('dj partial paid → keep paid period and split remaining', () => {
const next = applyExpandRemainingToInstallment(
{
payMode: 'dj',
contractAmount: 12000,
schedules: [
{
id: 's1',
scheduleNo: 1,
dueAmount: 12000,
transfers: [{ payStatus: 'yjf', transferAmount: 4000 }]
}
]
},
{
installmentCount: 4,
installmentIntervalMonths: 1,
installmentStartDate: '20261001',
installmentAdvanceDays: 0
}
)
assert.equal(next.payMode, 'fq')
assert.equal(next.schedules[0].dueAmount, 4000)
assert.equal(next.schedules.length, 4) // 1 kept + 3 new (4-1)
const newSum = next.schedules.slice(1).reduce((s, x) => s + Number(x.dueAmount), 0)
assert.equal(newSum, 8000)
assert.equal(next.schedules[1].dueDate, '20261001')
})
it('rejects when already cleared', () => {
assert.throws(
() =>
applyExpandRemainingToInstallment(
{
payMode: 'dj',
contractAmount: 1000,
schedules: [
{
scheduleNo: 1,
dueAmount: 1000,
transfers: [{ payStatus: 'yjf', transferAmount: 1000 }]
}
]
},
{ installmentCount: 6, installmentIntervalMonths: 1, installmentStartDate: '20261001' }
),
/已缴清/
)
})
})
describe('samplePaymentHasConfirmedTransfer', () => {
it('detects yjf', () => {
assert.equal(
samplePaymentHasConfirmedTransfer({
schedules: [{ transfers: [{ payStatus: 'dfj' }] }]
}),
false
)
assert.equal(
samplePaymentHasConfirmedTransfer({
schedules: [{ transfers: [{ payStatus: 'yjf', transferAmount: 1 }] }]
}),
true
)
})
})
+353 -12
View File
@@ -1,4 +1,12 @@
/** 按样本缴费 · 工具函数(payment_model_version=2) */
/** 按样本缴费 · 工具函数(payment_model_version=2)
* 合同金额唯一来源:样本优惠价 price;不再使用硬编码价目表。
*/
import {
calcFqInstallmentDueDate,
defaultInstallmentAmounts,
DEFAULT_INSTALLMENT_ADVANCE_DAYS
} from '@/utils/protocol-payment-installment'
export type SamplePaymentLike = {
contractAmount?: number | null
@@ -7,6 +15,10 @@ export type SamplePaymentLike = {
schedules?: ScheduleLike[]
}
export type SamplePriceLike = {
price?: number | string | null
}
export type TransferLike = {
payStatus?: string
transferAmount?: number | null
@@ -14,6 +26,8 @@ export type TransferLike = {
export type ScheduleLike = {
dueAmount?: number | null
dueDate?: string | null
scheduleStatus?: string
transfers?: TransferLike[]
}
@@ -21,6 +35,18 @@ export function sumSampleContractAmounts(payments: SamplePaymentLike[]): number
return payments.reduce((s, p) => s + (Number(p.contractAmount) || 0), 0)
}
/** 样本优惠价格(元);未填返回 null */
export function parsePreferentialPrice(
sample: SamplePriceLike | null | undefined
): number | null {
if (sample == null) return null
const raw = sample.price
if (raw == null || raw === '') return null
const n = Number(raw)
if (!Number.isFinite(n) || n < 0) return null
return Math.round(n * 100) / 100
}
export function protocolTotalMismatchHint(
total: number | null | undefined,
sampleSum: number
@@ -32,31 +58,118 @@ export function protocolTotalMismatchHint(
return null
}
function activeSchedules(pay: SamplePaymentLike): ScheduleLike[] {
return (pay.schedules || []).filter((s) => String(s.scheduleStatus || 'open') !== 'void')
}
/** 分期/分改趸:每期应缴金额与应缴时间必填 */
export function sampleScheduleRequiredFieldsHint(pay: SamplePaymentLike): string | null {
const mode = String(pay.payMode || '')
if (mode !== 'fq' && mode !== 'fgd' && mode !== 'djfq') return null
const schedules = activeSchedules(pay)
if (!schedules.length) return null
const code = String(pay.sampleCode || '').trim()
const prefix = code ? `样本 ${code} ` : ''
for (let i = 0; i < schedules.length; i++) {
const s = schedules[i]
const amt = Number(s.dueAmount)
if (!Number.isFinite(amt) || amt <= 0) {
return `${prefix}第 ${i + 1} 期应缴金额必须填写`
}
if (!String(s.dueDate || '').trim()) {
return `${prefix}第 ${i + 1} 期应缴时间必须填写`
}
}
return null
}
/** 单样本:未作废期应缴合计须等于合同金额;不一致返回提示文案 */
export function sampleScheduleDuesMismatchHint(pay: SamplePaymentLike): string | null {
const contract = Number(pay.contractAmount)
if (!Number.isFinite(contract)) return null
const schedules = activeSchedules(pay)
if (!schedules.length) return null
const sum = schedules.reduce((acc, x) => acc + (Number(x.dueAmount) || 0), 0)
if (Math.abs(contract - sum) > 0.015) {
const code = String(pay.sampleCode || '').trim()
const prefix = code ? `样本 ${code} ` : ''
return `${prefix}合同金额 ${contract.toFixed(2)} 与各期应缴合计 ${sum.toFixed(2)} 不一致,请调整后再保存`
}
return null
}
/** 展示口径已缴:与后端 chase_period_money / 缴费信息期次一致 */
const DISPLAY_PAID_STATUSES = new Set(['dfj', 'jfqrz', 'yjf'])
function sumTransferAmountByStatuses(
transfers: TransferLike[] | undefined,
statuses: Set<string>
): number {
return (transfers || [])
.filter((t) => statuses.has(String(t.payStatus || '')))
.reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
}
/**
* 期次已缴/欠缴(展示):已缴 = dfj + jfqrz + yjf;欠缴 = max(应缴 − 已缴, 0)
*/
export function schedulePaidArrears(
dueAmount: number,
transfers: TransferLike[]
): { paid: number; arrears: number } {
const paid = (transfers || [])
.filter((t) => t.payStatus === 'yjf')
.reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
const paid = Math.round(sumTransferAmountByStatuses(transfers, DISPLAY_PAID_STATUSES) * 100) / 100
const arrears = Math.round((dueAmount - paid) * 100) / 100
return { paid, arrears: arrears > 0 ? arrears : 0 }
}
/** 财务已确认口径(仅 yjf):用于分改趸并期等与后端 plan_fq_to_fgd_merge 对齐 */
export function scheduleFinanceConfirmedPaidArrears(
dueAmount: number,
transfers: TransferLike[]
): { paid: number; arrears: number } {
const paid =
Math.round(sumTransferAmountByStatuses(transfers, new Set(['yjf'])) * 100) / 100
return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 }
}
/** 单样本默认定价:沿用原「1 个样本」价目 */
export function resolvePerSamplePreset(payMode: 'dj' | 'fq'): {
contractAmount: number
/** 单样本合同金额 = 优惠价格;未填优惠价则 null */
export function resolveSampleContractAmount(
sample: SamplePriceLike | null | undefined
): number | null {
return parsePreferentialPrice(sample)
}
/** 新建/切换缴费模式:合同取优惠价;分期不预填各期金额 */
export function resolvePerSamplePaymentDefaults(
sample: SamplePriceLike | null | undefined,
payMode: 'dj' | 'fq'
): {
contractAmount: number | null
installmentCount?: number
installmentAmounts?: number[]
} | null {
if (payMode === 'dj') return { contractAmount: 22880 }
} {
const contractAmount = resolveSampleContractAmount(sample)
if (payMode === 'fq') {
return {
contractAmount: 25880,
contractAmount,
installmentCount: 6,
installmentAmounts: [6480, 3880, 3880, 3880, 3880, 3880]
installmentAmounts: []
}
}
return null
return { contractAmount }
}
/**
* 是否用优惠价覆盖合同:仅合同为空时(打开编辑不覆盖已有库内金额)。
* 用户改优惠价时由调用方 force=true 强制同步。
*/
export function shouldReplaceContractWithPreferentialPrice(
contractAmount: number | null | undefined,
preferentialPrice: number | null
): boolean {
if (preferentialPrice == null) return false
if (contractAmount == null || !Number.isFinite(Number(contractAmount))) return true
return false
}
export function parseInstallmentRatios(raw: unknown): number[] {
@@ -71,3 +184,231 @@ export function parseInstallmentRatios(raw: unknown): number[] {
}
return []
}
export type FgdScheduleLike = ScheduleLike & {
id?: string
scheduleNo?: number
_deleted?: boolean
}
export type FgdMergePlan = {
targetScheduleId: string
targetScheduleNo: number
newDueAmount: number
softDeleteScheduleIds: string[]
softDeleteScheduleNos: number[]
newInstallmentCount: number
}
function roundMoney(n: number): number {
return Math.round(n * 100) / 100
}
/** fq → fgd:保留已缴期,剩余欠缴并入第一期未清期;软删其后各期(与后端 plan_fq_to_fgd_merge 对齐) */
export function planFqToFgdMerge(schedules: FgdScheduleLike[]): FgdMergePlan {
const schList = (schedules || [])
.filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted)
.slice()
.sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
const arrearsOf = (s: FgdScheduleLike): number => {
const { arrears } = scheduleFinanceConfirmedPaidArrears(
Number(s.dueAmount) || 0,
s.transfers || []
)
return arrears > 0 ? arrears : 0
}
let target: FgdScheduleLike | undefined
for (const s of schList) {
if (arrearsOf(s) > 0) {
target = s
break
}
}
if (!target) {
target = schList.find((s) => String(s.scheduleStatus || 'open') !== 'cleared')
}
if (!target) {
throw new Error('没有可合并的未清期次')
}
const targetNo = Number(target.scheduleNo || 0)
const later = schList.filter((s) => Number(s.scheduleNo || 0) > targetNo)
for (const s of later) {
if ((s.transfers || []).some((t) => t.payStatus === 'yjf')) {
throw new Error('后续期已有已确认转账,无法合并为分改趸')
}
}
const remain = roundMoney(schList.reduce((sum, s) => sum + arrearsOf(s), 0))
const { paid: targetConfirmed } = scheduleFinanceConfirmedPaidArrears(
Number(target.dueAmount) || 0,
target.transfers || []
)
const newDueAmount = roundMoney(targetConfirmed + remain)
const softDeleteScheduleIds = later
.map((s) => String(s.id || '').trim())
.filter(Boolean)
const softDeleteScheduleNos = later.map((s) => Number(s.scheduleNo || 0))
const softNoSet = new Set(softDeleteScheduleNos)
const kept = schList.filter((s) => !softNoSet.has(Number(s.scheduleNo || 0)))
return {
targetScheduleId: String(target.id || ''),
targetScheduleNo: targetNo,
newDueAmount,
softDeleteScheduleIds,
softDeleteScheduleNos,
newInstallmentCount: kept.length
}
}
export type SamplePayMode = 'dj' | 'fq' | 'fgd'
export function samplePaymentHasConfirmedTransfer(pay: {
schedules?: FgdScheduleLike[]
}): boolean {
return (pay.schedules || []).some((s) =>
(s.transfers || []).some((t) => t.payStatus === 'yjf')
)
}
/**
* 下拉可选缴费模式(趸交 ↔ 分改趸 互斥,不可直转):
* - 分期:可改趸交 / 分改趸
* - 趸交:仅可改分期
* - 分改趸:仅可改分期
*/
export function availableSamplePayModes(pay: {
payMode?: string
schedules?: FgdScheduleLike[]
}): SamplePayMode[] {
const mode = String(pay.payMode || 'dj') as SamplePayMode
if (mode === 'fq') return ['fq', 'dj', 'fgd']
if (mode === 'fgd') return ['fgd', 'fq']
return ['dj', 'fq']
}
/** 将剩余欠缴并入当前未清期,软删后续未缴期(fq→dj / fq→fgd) */
export function applyConsolidateRemainingToPayment<T extends Record<string, any>>(
pay: T,
targetMode: 'dj' | 'fgd'
): T {
const plan = planFqToFgdMerge((pay.schedules || []) as FgdScheduleLike[])
const softNoSet = new Set(plan.softDeleteScheduleNos)
const schedules = ((pay.schedules || []) as FgdScheduleLike[])
.filter(
(s) =>
String(s.scheduleStatus || 'open') !== 'void' &&
!s._deleted &&
!softNoSet.has(Number(s.scheduleNo || 0))
)
.map((s) => {
if (Number(s.scheduleNo || 0) !== plan.targetScheduleNo) return { ...s }
return { ...s, dueAmount: plan.newDueAmount }
})
.sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
return {
...pay,
payMode: targetMode,
installmentCount: plan.newInstallmentCount,
schedules,
installmentRatios: schedules.map((s) => {
const n = Number(s.dueAmount)
return Number.isFinite(n) && n > 0 ? n : 0
})
}
}
/** 将并期结果写回样本缴费方案(前端即时预览;保存时后端仍会按库内数据再执行一遍) */
export function applyFqToFgdMergeToPayment<T extends Record<string, any>>(pay: T): T {
return applyConsolidateRemainingToPayment(pay, 'fgd')
}
export type ExpandInstallmentOpts = {
installmentCount?: number | null
installmentIntervalMonths?: number | null
installmentStartDate?: string
installmentAdvanceDays?: number | null
}
/** dj/fgd → fq:保留已确认实缴期,剩余按分期参数拆成新期 */
export function applyExpandRemainingToInstallment<T extends Record<string, any>>(
pay: T,
opts: ExpandInstallmentOpts
): T {
const schList = ((pay.schedules || []) as FgdScheduleLike[])
.filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted)
.slice()
.sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
for (const s of schList) {
if ((s.transfers || []).some((t) => t.payStatus === 'jfqrz')) {
throw new Error('存在缴费确认中的转账,请先完成财务确认')
}
}
let totalPaid = 0
const kept: FgdScheduleLike[] = []
for (const s of schList) {
const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(
Number(s.dueAmount) || 0,
s.transfers || []
)
totalPaid = roundMoney(totalPaid + paid)
if (paid > 0) {
kept.push({
...s,
dueAmount: arrears > 0 ? paid : s.dueAmount,
scheduleStatus: arrears > 0 ? 'cleared' : s.scheduleStatus || 'cleared',
transfers: [...(s.transfers || [])]
})
}
}
const contract = Number(pay.contractAmount)
if (!Number.isFinite(contract)) {
throw new Error('请先填写样本合同金额')
}
const remaining = roundMoney(contract - totalPaid)
if (remaining <= 0) {
throw new Error('已缴清,无法改为分期')
}
const count = Math.max(1, Number(opts.installmentCount) || 6)
const newCount = Math.max(1, count - kept.length)
const interval = Number(opts.installmentIntervalMonths) || 1
const startDate = String(opts.installmentStartDate || '').trim()
if (!startDate) {
throw new Error('请先填写分期开始时间')
}
const advanceDays = Number(
opts.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
)
const amounts = defaultInstallmentAmounts(remaining, newCount)
const maxNo = kept.reduce((m, s) => Math.max(m, Number(s.scheduleNo) || 0), 0)
const created: FgdScheduleLike[] = amounts.map((amt, i) => ({
scheduleNo: maxNo + 1 + i,
dueAmount: amt,
dueDate: calcFqInstallmentDueDate(startDate, i, interval, advanceDays),
scheduleStatus: 'open',
transfers: []
}))
const schedules = [...kept, ...created]
return {
...pay,
payMode: 'fq',
installmentCount: schedules.length,
installmentIntervalMonths: interval,
installmentStartDate: startDate,
installmentAdvanceDays: advanceDays,
schedules,
installmentRatios: schedules.map((s) => {
const n = Number(s.dueAmount)
return Number.isFinite(n) && n > 0 ? n : 0
})
}
}
@@ -1,443 +0,0 @@
import { parseChineseDatetimeText } from '@/utils/chinese-datetime-parse'
import { parseBirthOrder } from '@/utils/chinese-numeral-parse'
import {
isBlankString,
matchCellSampleType,
resolveTransportType
} from '@/utils/donation-standard-import-parse'
import { extractNumericPart } from '@/utils/numeric-input-parse'
import {
SAMPLE_TYPE_PERINATAL_HSC,
SAMPLE_TYPE_UC_MSC
} from '@/utils/sample-production-reception-fields'
export type ProtocolSampleImportHint = {
typeKey: 'hsc' | 'uc_msc'
typeName: string
sampleCode?: string
collectTime?: string
}
export type ProtocolPaymentTransferHint = {
transferAmount: number
transferTime: string
}
export type ProtocolStandardImportParsed = {
sampleTypeHeader: string
sampleHints: ProtocolSampleImportHint[]
motherName?: string
motherPhone?: string
newbornSex?: '1' | '0'
newbornWeightGram?: number | null
apgarScore?: number | null
newbornDeliveryTime?: string
sampleCollectTime?: string
maternalBloodCollectTime?: string
maternalBloodExplicitEmpty?: boolean
deliveryMode?: string
birthOrder?: number | null
medicalStaff?: string
hospitalName?: string
salesUserName?: string
payMode: 'dj' | 'fq' | 'djfq'
contractAmount?: number | null
transferTypeLabel?: string
paymentTransfers: ProtocolPaymentTransferHint[]
transportModeText?: string
transportType?: 'kdys' | 'ryxd'
carrierName?: string
etaTime?: string
remark?: string
}
const MATERNAL_BLOOD_EMPTY_MARKERS = ['未采集', '无', '暂无', '—', '-', '未采']
const SAMPLE_CODE_RE = /^ZK[A-Z0-9]+$/i
const KEY_ALIASES: Record<string, string> = {
产妇姓名: 'motherName',
母亲姓名: 'motherName',
孕妇电话: 'motherPhone',
母亲联系电话: 'motherPhone',
新生儿性别: 'newbornSex',
新生儿体重: 'newbornWeightGram',
'新生儿体重(克)': 'newbornWeightGram',
新生儿评分: 'apgarScore',
分娩时间: 'newbornDeliveryTime',
新生儿分娩时间: 'newbornDeliveryTime',
母血采集时间: 'maternalBloodCollectTime',
分娩方式: 'deliveryMode',
胎次: 'birthOrder',
采集医护: 'medicalStaff',
采集医院: 'hospitalName',
销售人员: 'salesUserName',
采集专员: 'salesUserName',
缴费方式: 'payModeText',
转账方式: 'transferTypeText',
合同金额: 'contractAmount',
转账金额: 'transferAmount',
转账时间: 'transferTime',
运输方式: 'transportModeText',
预计送达时间: 'etaTime',
备注: 'remark'
}
function normalizeLine(line: string): string {
return line
.replace(/^[\s【】]+|[\s【】]+$/g, '')
.trim()
}
function splitKeyValue(line: string): { key: string; value: string } | null {
const match = line.match(/^([^::]+)[::]\s*(.*)$/)
if (!match) return null
return { key: match[1].trim(), value: match[2].trim() }
}
function normalizeChineseDatetimeLiteral(value: string): string {
let v = (value || '').trim()
v = v.replace(
/^(\d{4})年(\d{1,2})月(\d{1,2})日(\d{1,2}):(\d{1,2})(?::(\d{1,2}))?$/,
(_, y, mo, d, h, mi, s) => `${y}年${Number(mo)}月${Number(d)}日${Number(h)}时${Number(mi)}分${s ? Number(s) + '秒' : ''}`
)
return v
}
function parseDatetime(value: string, minutePrecision = false): string | undefined {
const v = normalizeChineseDatetimeLiteral(value.trim())
if (!v) return undefined
if (MATERNAL_BLOOD_EMPTY_MARKERS.some((m) => v === m || v.includes(m))) {
return undefined
}
return (
parseChineseDatetimeText(v, minutePrecision ? 'datetime-minute' : 'datetime') || undefined
)
}
function parseSex(value: string): '1' | '0' | undefined {
const v = value.trim()
if (!v) return undefined
if (/^男|^男孩/.test(v)) return '1'
if (/^女|^女孩/.test(v)) return '0'
return undefined
}
function parseDeliveryMode(value: string): string | undefined {
const v = value.trim()
if (!v) return undefined
if (/剖宫产|剖腹产|刨宫产/.test(v)) return 'pgc'
if (/顺产|自然分娩/.test(v)) return 'sc'
return undefined
}
function parseMoney(value: string): number | null | undefined {
const num = extractNumericPart(value)
if (num == null || num === '') return undefined
const n = Number(num)
return Number.isFinite(n) ? n : undefined
}
function parseScore(value: string): number | null | undefined {
return parseMoney(value)
}
function isMaternalBloodEmpty(value: string): boolean {
const v = value.trim()
if (!v) return true
return MATERNAL_BLOOD_EMPTY_MARKERS.some((m) => v === m || v.includes(m))
}
function resolveSampleCollectHint(key: string): Pick<ProtocolSampleImportHint, 'typeKey' | 'typeName'> | null {
const k = key.trim()
if (/脐带间充质.*采集时间/.test(k) || k === '脐带采集时间') {
return { typeKey: 'uc_msc', typeName: SAMPLE_TYPE_UC_MSC }
}
if (/造血干细胞.*采集时间/.test(k) || k === '样本采集时间') {
return { typeKey: 'hsc', typeName: SAMPLE_TYPE_PERINATAL_HSC }
}
return null
}
function resolveSampleCodeHint(
key: string,
value: string
): (Pick<ProtocolSampleImportHint, 'typeKey' | 'typeName'> & { sampleCode: string }) | null {
const code = value.trim().toUpperCase()
if (!SAMPLE_CODE_RE.test(code)) return null
const k = key.trim()
if (/脐带间充质/.test(k)) {
return { typeKey: 'uc_msc', typeName: SAMPLE_TYPE_UC_MSC, sampleCode: code }
}
if (/造血干细胞/.test(k)) {
return { typeKey: 'hsc', typeName: SAMPLE_TYPE_PERINATAL_HSC, sampleCode: code }
}
return null
}
function upsertSampleHint(
hints: ProtocolSampleImportHint[],
partial: Partial<ProtocolSampleImportHint> & Pick<ProtocolSampleImportHint, 'typeKey' | 'typeName'>
) {
let hint = hints.find((h) => h.typeKey === partial.typeKey)
if (!hint) {
hint = { typeKey: partial.typeKey, typeName: partial.typeName }
hints.push(hint)
}
if (partial.sampleCode) hint.sampleCode = partial.sampleCode
if (partial.collectTime) hint.collectTime = partial.collectTime
}
function earliestCollectTime(hints: ProtocolSampleImportHint[]): string | undefined {
const times = hints.map((h) => h.collectTime).filter(Boolean) as string[]
if (!times.length) return undefined
return times.sort((a, b) => a.localeCompare(b))[0]
}
/** 缴费方式:趸交+分期 → djfq;仅「分期」→ fq;其余 → dj */
export function parsePayModeFromText(value: string): {
payMode: 'dj' | 'fq' | 'djfq' | 'djfq'
transferLabel?: string
} {
const v = (value || '').trim()
if (/趸交\s*[++]\s*分期|趸交.*分期/.test(v)) return { payMode: 'djfq' }
if (/分期/.test(v)) return { payMode: 'fq' }
if (/微信|支付宝|银行卡|银行|现金/.test(v)) {
return { payMode: 'dj', transferLabel: v }
}
return { payMode: 'dj' }
}
/** 解析转账时间行(支持多笔:…2000元,…付20880元) */
export function parsePaymentTransferSegments(
text: string,
fallbackAmount?: number | null
): ProtocolPaymentTransferHint[] {
const raw = (text || '').trim()
if (!raw) {
if (fallbackAmount != null && fallbackAmount > 0) {
return [{ transferAmount: fallbackAmount, transferTime: '' }]
}
return []
}
const segments = raw.split(/[,,]/).map((s) => s.trim()).filter(Boolean)
const results: ProtocolPaymentTransferHint[] = []
let defaultYear: number | undefined
for (const seg of segments) {
const amountMatch = seg.match(/(?:付)?(\d+(?:\.\d+)?)\s*元\s*$/)
if (!amountMatch) continue
const transferAmount = Number(amountMatch[1])
if (!Number.isFinite(transferAmount)) continue
let dtRaw = seg.slice(0, seg.length - amountMatch[0].length).trim()
dtRaw = normalizeChineseDatetimeLiteral(dtRaw)
if (/^\d{1,2}月/.test(dtRaw) && defaultYear) {
dtRaw = `${defaultYear}年${dtRaw}`
}
const transferTime = parseChineseDatetimeText(dtRaw, 'datetime')
if (transferTime) {
if (!defaultYear) defaultYear = Number(transferTime.slice(0, 4))
results.push({ transferAmount, transferTime })
}
}
if (results.length) return results
const transferTime = parseChineseDatetimeText(normalizeChineseDatetimeLiteral(raw), 'datetime')
if (fallbackAmount != null && fallbackAmount > 0) {
return [{ transferAmount: fallbackAmount, transferTime: transferTime || '' }]
}
if (transferTime) {
return [{ transferAmount: fallbackAmount ?? 0, transferTime }]
}
return []
}
/** 字典 label → transfer_type 值(wxzz 等) */
export function resolveTransferTypeCode(
label: string,
options: Array<{ value: string; label: string }>
): string {
const v = (label || '').trim()
if (!v || !options.length) return ''
const exact = options.find((o) => o.label === v || o.value === v)
if (exact) return exact.value
const contains = options.filter((o) => o.label.includes(v) || v.includes(o.label))
if (contains.length === 1) return contains[0].value
const keywordMap: Array<[RegExp, string]> = [
[/微信/, '微信'],
[/支付宝/, '支付宝'],
[/银行/, '银行'],
[/现金/, '现金']
]
for (const [re, kw] of keywordMap) {
if (!re.test(v)) continue
const hit = options.find((o) => o.label.includes(kw))
if (hit) return hit.value
}
return ''
}
/** 从运输描述中提取携带人姓名(如「露露帮助…」) */
export function extractCarrierNameFromTransport(text: string): string | undefined {
const v = (text || '').trim()
const m = v.match(/^([\u4e00-\u9fa5]{1,4})(?:帮助|协助|取)/)
return m?.[1]
}
/** 解析采集协议单一键导入标准文本 */
export function parseProtocolStandardImportText(text: string): ProtocolStandardImportParsed | null {
const raw = (text || '').trim()
if (!raw) return null
const lines = raw
.split(/\r?\n/)
.map(normalizeLine)
.filter(Boolean)
if (!lines.length) return null
const result: ProtocolStandardImportParsed = {
sampleTypeHeader: lines[0],
sampleHints: [],
payMode: 'dj',
paymentTransfers: []
}
let transferTimeRaw = ''
let transferAmountSingle: number | null | undefined
for (let i = 1; i < lines.length; i++) {
const kv = splitKeyValue(lines[i])
if (!kv) continue
const codeHint = resolveSampleCodeHint(kv.key, kv.value)
if (codeHint) {
upsertSampleHint(result.sampleHints, codeHint)
continue
}
const collectMeta = resolveSampleCollectHint(kv.key)
if (collectMeta) {
const dt = parseDatetime(kv.value, true)
if (dt) upsertSampleHint(result.sampleHints, { ...collectMeta, collectTime: dt })
continue
}
const field = KEY_ALIASES[kv.key]
if (!field) continue
switch (field) {
case 'motherName':
result.motherName = kv.value
break
case 'motherPhone':
result.motherPhone = kv.value.replace(/\s+/g, '')
break
case 'newbornSex': {
const sex = parseSex(kv.value)
if (sex) result.newbornSex = sex
break
}
case 'newbornWeightGram': {
const w = parseMoney(kv.value)
if (w !== undefined) result.newbornWeightGram = w
break
}
case 'apgarScore': {
const s = parseScore(kv.value)
if (s !== undefined) result.apgarScore = s
break
}
case 'newbornDeliveryTime': {
const dt = parseDatetime(kv.value)
if (dt) result.newbornDeliveryTime = dt
break
}
case 'maternalBloodCollectTime':
if (isMaternalBloodEmpty(kv.value)) {
result.maternalBloodExplicitEmpty = true
} else {
const dt = parseDatetime(kv.value, true)
if (dt) result.maternalBloodCollectTime = dt
}
break
case 'deliveryMode': {
const mode = parseDeliveryMode(kv.value)
if (mode) result.deliveryMode = mode
break
}
case 'birthOrder': {
const n = parseBirthOrder(kv.value)
if (n !== undefined) result.birthOrder = n
break
}
case 'medicalStaff':
result.medicalStaff = kv.value
break
case 'hospitalName':
if (!result.hospitalName) result.hospitalName = kv.value
break
case 'salesUserName':
result.salesUserName = kv.value
break
case 'payModeText': {
const pm = parsePayModeFromText(kv.value)
result.payMode = pm.payMode
if (pm.transferLabel && !result.transferTypeLabel) {
result.transferTypeLabel = pm.transferLabel
}
break
}
case 'transferTypeText':
result.transferTypeLabel = kv.value
break
case 'contractAmount': {
const amt = parseMoney(kv.value)
if (amt !== undefined) result.contractAmount = amt
break
}
case 'transferAmount': {
const amt = parseMoney(kv.value)
if (amt !== undefined) transferAmountSingle = amt
break
}
case 'transferTime':
transferTimeRaw = kv.value
break
case 'transportModeText': {
result.transportModeText = kv.value
result.transportType = resolveTransportType(kv.value) ?? undefined
result.carrierName = extractCarrierNameFromTransport(kv.value)
break
}
case 'etaTime': {
const dt = parseDatetime(kv.value)
if (dt) result.etaTime = dt
break
}
case 'remark':
result.remark = kv.value
break
default:
break
}
}
result.paymentTransfers = parsePaymentTransferSegments(transferTimeRaw, transferAmountSingle ?? null)
if (result.sampleHints.length) {
result.sampleCollectTime = earliestCollectTime(result.sampleHints)
}
return result
}
export { matchCellSampleType, isBlankString }
@@ -0,0 +1,98 @@
import type { SampleFreight } from '@/api/sample-freight'
export type SampleFreightFlatRow = {
rowKey: string
freightId: string
/** 当前列表内批次序号(从 0 起,用于斑马纹) */
batchIndex: number
/** 运费批次号 YF+YYYYMMDD+001~999 */
batchNo: string
rowspan: number
isFirst: boolean
transportMode: string
transportModeLabel: string
freightAmount: number
linkedSampleCount: number
createBy?: string
createByName?: string
createTime: string
sampleSource?: string
protocolNo?: string
motherName?: string
newbornName?: string
sampleCode?: string
sampleTypeName?: string
sampleId?: string
protocolId?: string
/** 原始批次,选中时回传 */
freight: SampleFreight
}
export function flattenSampleFreights(list: SampleFreight[]): SampleFreightFlatRow[] {
const rows: SampleFreightFlatRow[] = []
list.forEach((f, batchIndex) => {
const items = f.items?.length ? f.items : [null]
const batchNo = (f.freightNo || '').trim() || '—'
items.forEach((it, idx) => {
rows.push({
rowKey: `${f.id}|${it?.sampleId || idx}`,
freightId: f.id,
batchIndex,
batchNo,
rowspan: idx === 0 ? items.length : 0,
isFirst: idx === 0,
transportMode: f.transportMode,
transportModeLabel: f.transportModeLabel || f.transportMode,
freightAmount: f.freightAmount,
linkedSampleCount: f.linkedSampleCount ?? (f.items?.length || 0),
createBy: f.createBy || '',
createByName: f.createByName || f.createBy || '',
createTime: f.createTime || '',
sampleSource: it?.sampleSource,
protocolNo: it?.protocolNo,
motherName: it?.motherName,
newbornName: it?.newbornName,
sampleCode: it?.sampleCode,
sampleTypeName: it?.sampleTypeName,
sampleId: it?.sampleId,
protocolId: it?.protocolId,
freight: f
})
})
})
return rows
}
const DEFAULT_MERGE_PROPS = new Set([
'selection',
'radio',
'batchNo',
'transportMode',
'freightAmount',
'linkedSampleCount',
'createByName',
'createTime',
'actions'
])
export function sampleFreightSpanMethod(
{
column,
row
}: {
column: { type?: string; property?: string }
row: SampleFreightFlatRow
},
extraMergeProps: string[] = []
): { rowspan: number; colspan: number } {
const mergeProps =
extraMergeProps.length > 0
? new Set([...DEFAULT_MERGE_PROPS, ...extraMergeProps])
: DEFAULT_MERGE_PROPS
const prop = column.type === 'selection' ? 'selection' : column.property || ''
if (mergeProps.has(prop)) {
if (row.rowspan > 0) return { rowspan: row.rowspan, colspan: 1 }
return { rowspan: 0, colspan: 0 }
}
return { rowspan: 1, colspan: 1 }
}
+57 -1
View File
@@ -98,6 +98,45 @@ export const sampleStatus: StatusRegistry = {
export const sampleStatusLabel = createLabelGetter(sampleStatus)
export const sampleStatusTagType = createTagTypeGetter(sampleStatus)
export const sampleCollectionFlag: StatusRegistry = {
uncollected: { label: '未采集', tagType: 'info' },
collected: { label: '已采集', tagType: 'success' },
voided: { label: '已作废', tagType: 'danger' }
}
export const sampleCollectionFlagLabel = createLabelGetter(sampleCollectionFlag)
export const sampleCollectionFlagTagType = createTagTypeGetter(sampleCollectionFlag)
export const sampleReceptionFlag: StatusRegistry = {
none: { label: '未接收', tagType: 'info' },
js: { label: '已接收', tagType: 'success' },
bj: { label: '拒收', tagType: 'danger' }
}
export const sampleReceptionFlagLabel = createLabelGetter(sampleReceptionFlag)
export const sampleReceptionFlagTagType = createTagTypeGetter(sampleReceptionFlag)
export const sampleQcFlag: StatusRegistry = {
incomplete: { label: '未完成', tagType: 'warning' },
complete: { label: '已完成', tagType: 'success' },
voided: { label: '已作废', tagType: 'danger' }
}
export const sampleQcFlagLabel = createLabelGetter(sampleQcFlag)
export const sampleQcFlagTagType = createTagTypeGetter(sampleQcFlag)
export const sampleStorageFlag: StatusRegistry = {
none: { label: '未入库', tagType: 'info' },
yrk: { label: '已入库', tagType: 'success' },
rejected: { label: '拒绝入库', tagType: 'danger' }
}
export const sampleStorageFlagLabel = createLabelGetter(sampleStorageFlag)
export const sampleStorageFlagTagType = createTagTypeGetter(sampleStorageFlag)
export const handleDoneFlag: StatusRegistry = {
'1': { label: '已办理', tagType: 'success' },
'0': { label: '未办理', tagType: 'info' }
}
export const handleDoneFlagLabel = createLabelGetter(handleDoneFlag)
export const handleDoneFlagTagType = createTagTypeGetter(handleDoneFlag)
export const payStatus: StatusRegistry = {
dfj: { label: '已缴费', tagType: 'primary' },
jfqrz: { label: '缴费确认中', tagType: 'warning' },
@@ -118,11 +157,28 @@ export const auditStatusTagType = createTagTypeGetter(auditStatus)
export const payMode: StatusRegistry = {
dj: { label: '趸交', tagType: 'primary' },
fq: { label: '分期', tagType: 'warning' },
djfq: { label: '趸交+分期', tagType: 'warning' }
djfq: { label: '趸交+分期', tagType: 'warning' },
fgd: { label: '分改趸', tagType: 'warning' }
}
export const payModeLabel = createLabelGetter(payMode)
export const payModeTagType = createTagTypeGetter(payMode)
export const paymentChaseStatus: StatusRegistry = {
overdue: { label: '已逾期', tagType: 'danger' },
upcoming: { label: '即将到期', tagType: 'warning' }
}
export const paymentChaseStatusLabel = createLabelGetter(paymentChaseStatus)
export const paymentChaseStatusTagType = createTagTypeGetter(paymentChaseStatus)
export const periodFinanceAudit: StatusRegistry = {
dfj: { label: '待缴费', tagType: 'info' },
jfqrz: { label: '确认中', tagType: 'warning' },
yjf: { label: '已通过', tagType: 'success' },
partial: { label: '部分到账', tagType: 'primary' }
}
export const periodFinanceAuditLabel = createLabelGetter(periodFinanceAudit)
export const periodFinanceAuditTagType = createTagTypeGetter(periodFinanceAudit)
/** 按样本列表整体进度(勿与转账笔 payStatus 混用:payStatus.dfj=已缴费) */
export const samplePaymentProgress: StatusRegistry = {
dfj: { label: '待缴费', tagType: 'info' },