444 lines
13 KiB
TypeScript
444 lines
13 KiB
TypeScript
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 }
|