feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强
汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2d3472f5c4
commit
db31990ebe
235 files changed
+14898
-10196
No files matched your search
@@ -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, [])
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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' },
|
||||
|
||||
Reference in new issue
Block a user