This commit is contained in:
zlwu committed 2026-07-02 12:25:00 +08:00
1 parent 038a60b09e
commit 3740a7dd80
45 files changed
+10945 -6192

No files matched your search

@@ -34,13 +34,41 @@
scroll-to-error
class="protocol-form"
>
<!-- Tab 1: 基本信息 -->
<div id="form-section-basic" class="form-tab-pane">
<!-- Tab 1: 基本信息(新增时左侧一键导入、右侧基本信息) -->
<div
id="form-section-basic"
class="form-tab-pane"
:class="{ 'form-tab-pane--import-basic': !isEdit }"
>
<div v-if="!isEdit" class="section-card section-card--import">
<div class="section-title">
<div class="section-title-left"><h3>一键导入</h3></div>
</div>
<el-form-item label="标准信息">
<el-input
v-model="standardImportText"
type="textarea"
:rows="12"
placeholder="请粘贴标准格式信息,点击「一键导入」填充各 Tab 字段"
/>
</el-form-item>
<div class="import-actions">
<el-button
class="btn-main"
type="primary"
:loading="standardImportLoading"
@click="handleStandardImport"
>
<el-icon><Upload /></el-icon>
一键导入
</el-button>
</div>
</div>
<div class="section-card">
<div class="section-title">
<div class="section-title-left"><h3>基本信息</h3></div>
</div>
<div class="form-grid form-grid--col4">
<div class="form-grid" :class="isEdit ? 'form-grid--col4' : 'form-grid--col2'">
<el-form-item label="协议状态" prop="protocolStatus">
<el-select
v-model="formData.protocolStatus"
@@ -673,13 +701,12 @@
<div class="detail-body-inner">
<div class="form-grid form-grid--col4">
<el-form-item label="应缴费金额(元)">
<el-input
<AmountInput
v-model="detail.dueAmount"
type="number"
step="any"
:min="0"
disabled
style="width: 100%"
:disabled="isPayDetailLocked(detail)"
placeholder="请输入应缴费金额"
@change="onPaymentDetailDueAmountChange(dIdx)"
/>
</el-form-item>
<el-form-item label="转账方式">
@@ -891,7 +918,8 @@
Loading,
Download,
ArrowDown,
ArrowRight
ArrowRight,
Upload
} from '@element-plus/icons-vue'
import {
fetchCollectionProtocolDetail,
@@ -906,6 +934,17 @@
useIndexedAttachmentDropZone
} from '@/composables/useAttachmentDropZone'
import { formatDatetimeToMinute } from '@/utils/chinese-datetime-parse'
import {
matchExpressCompany,
matchSampleTypeByExactName
} from '@/utils/donation-standard-import-parse'
import {
parseProtocolStandardImportText,
resolveTransferTypeCode,
isBlankString,
matchCellSampleType,
type ProtocolStandardImportParsed
} from '@/utils/protocol-standard-import-parse'
import {
fetchCustomerList,
fetchUserList,
@@ -914,6 +953,7 @@
fetchItemUnitList,
fetchCollectionRegionList,
fetchHospitalList,
fetchSearchCollectionHospitals,
fetchSampleCodeList,
fetchExpressCompanyList,
fetchSiteList
@@ -1165,6 +1205,8 @@
const dialogFormLoading = ref(false)
const dialogTitle = ref('新增协议单')
const isEdit = ref(false)
const standardImportText = ref('')
const standardImportLoading = ref(false)
const submitLoading = ref(false)
const editingId = ref<string | null>(null)
const formRef = ref()
@@ -1302,6 +1344,7 @@
expressCompanyOptions.value = []
activeFormTab.value = 'basic'
lastConfirmedPayMode.value = 'dj'
standardImportText.value = ''
lastConfirmedFqParams.value = {
installmentCount: null,
installmentIntervalMonths: null,
@@ -1862,7 +1905,6 @@
submitLoading.value = true
try {
syncFqPaymentDetailRowCount(fqPaymentContext(), formData.paymentDetails)
syncPaymentDueAmounts()
const protocolAttachmentUrls = formData.attachments
.map((a) => a.fileUrl)
.filter(Boolean)
@@ -2246,6 +2288,527 @@
ensureSitesInOptions(formData.samples)
}
function formatNowDatetime(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
}
/** 一键导入前清空上次导入/手填的可覆盖字段(保留协议状态、登记日期、附件等) */
function clearFormBeforeStandardImport() {
customerId.value = null
customerSnapshot.value = null
customerOptions.value = []
formData.customerId = ''
formData.motherName = ''
formData.motherPhone = ''
formData.motherIdCard = ''
formData.motherBirthYm = ''
formData.motherEmail = ''
formData.motherAddress = ''
formData.fatherName = ''
formData.fatherPhone = ''
formData.fatherIdCard = ''
formData.newbornName = ''
formData.newbornIdCard = ''
formData.expectedDeliveryDate = ''
formData.newbornSex = ''
formData.newbornWeightGram = null
formData.apgar1min = null
formData.apgar5min = null
formData.newbornDeliveryTime = ''
formData.deliveryMode = ''
formData.birthOrder = null
formData.regionId = ''
formData.hospitalId = ''
formData.medicalStaff = ''
formData.sampleCollectTime = ''
formData.maternalBloodCollectTime = ''
regionOptions.value = []
hospitalOptions.value = []
collectorUserId.value = null
collectorSnapshot.value = null
collectorUserOptions.value = []
formData.collectorUserId = ''
salesUserId.value = null
salesSnapshot.value = null
salesUserOptions.value = []
formData.salesUserId = ''
formData.samples = []
sampleCodeOptions.value = []
siteOptions.value = []
expressCompanyOptions.value = []
formData.payMode = 'dj'
formData.contractAmount = null
formData.lumpSumDeadline = ''
formData.installmentCount = null
formData.installmentIntervalMonths = null
formData.installmentStartDate = ''
formData.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
formData.installmentRatios = []
formData.paymentDetails = []
lastConfirmedPayMode.value = 'dj'
lastConfirmedFqParams.value = {
installmentCount: null,
installmentIntervalMonths: null,
installmentStartDate: '',
installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
installmentRatios: [] as number[]
}
formData.remark = ''
}
async function applyMotherByName(name: string): Promise<'linked' | 'name-only' | 'skipped'> {
const keyword = (name || '').trim()
if (!keyword) return 'skipped'
if (!isBlankString(formData.customerId)) return 'skipped'
if (!isBlankString(formData.motherName)) return 'skipped'
try {
const res = await fetchCustomerList({ page: 1, pageSize: 50, keyword })
const list = res.list || []
const match =
list.find((c) => c.customerName === keyword) ||
list.find((c) => (c.customerName || '').includes(keyword))
if (match) {
customerOptions.value = [match, ...list.filter((c) => c.id !== match.id)]
onCustomerChange(match.id)
return 'linked'
}
formData.motherName = keyword
return 'name-only'
} catch {
formData.motherName = keyword
return 'name-only'
}
}
async function applyHospitalByName(name: string): Promise<boolean> {
const keyword = (name || '').trim()
if (!keyword || !isBlankString(formData.hospitalId)) return false
try {
const res = await fetchSearchCollectionHospitals({ keyword, limit: 20 })
const list = res.list || []
const match =
list.find((h) => h.hospitalName === keyword) ||
list.find((h) => (h.hospitalName || '').includes(keyword)) ||
list[0]
if (!match) return false
const regionStub: Api.BizCollectionRegion.RegionItem = {
id: match.regionId,
regionName: match.regionName || match.regionId,
hospitalNames: [],
tenantNo: match.tenantNo || '',
orderNum: 0,
remark: '',
createTime: '',
updateTime: ''
}
if (!regionOptions.value.some((r) => r.id === match.regionId)) {
regionOptions.value = [regionStub, ...regionOptions.value]
}
formData.regionId = match.regionId
await loadHospitalsByRegion(match.regionId, keyword)
if (!hospitalOptions.value.some((h) => h.id === match.id)) {
hospitalOptions.value = [match, ...hospitalOptions.value]
}
formData.hospitalId = match.id
return true
} catch {
return false
}
}
async function applySalesUserByName(name: string): Promise<boolean> {
const keyword = (name || '').trim()
if (!keyword) return false
try {
const res = await fetchUserList({
page: 1,
pageSize: 50,
keyword,
status: '1'
})
const list = res.list || []
const match =
list.find((u) => u.nickName === keyword) ||
list.find((u) => (u.nickName || '').includes(keyword))
if (!match) return false
applyUserToCollector(match)
applyUserToSales(match)
return true
} catch {
return false
}
}
function applyUserToCollector(u: Api.User.UserItem) {
collectorSnapshot.value = { ...u }
collectorUserId.value = u.id
formData.collectorUserId = u.id
if (!collectorUserOptions.value.some((x) => x.id === u.id)) {
collectorUserOptions.value = [u, ...collectorUserOptions.value]
}
}
function applyUserToSales(u: Api.User.UserItem) {
salesSnapshot.value = { ...u }
salesUserId.value = u.id
formData.salesUserId = u.id
if (!salesUserOptions.value.some((x) => x.id === u.id)) {
salesUserOptions.value = [u, ...salesUserOptions.value]
}
}
async function resolveSampleCodeByCode(code: string) {
const keyword = (code || '').trim()
if (!keyword) return null
const res = await fetchSampleCodeList({ page: 1, pageSize: 50, keyword })
const list = (res.list || []) as Api.BizSampleCode.SampleCodeItem[]
return (
list.find((sc) => sc.sampleCode === keyword) ||
list.find((sc) => String(sc.sampleCode || '').includes(keyword)) ||
null
)
}
function applyParsedTransportToSample(
sample: Record<string, any>,
parsed: ProtocolStandardImportParsed,
warnings: string[],
filledLabels: string[]
) {
if (!parsed.transportType) return
const transport = { ...(sample.transport || createEmptyTransport()) }
let transportFilled = false
if (isBlankString(transport.transportType)) {
transport.transportType = parsed.transportType
transportFilled = true
}
if (isBlankString(transport.destSiteId) && siteOptions.value.length) {
const site = siteOptions.value[0]
transport.destSiteId = site.id
transportFilled = true
}
if (parsed.etaTime && isBlankString(transport.etaTime)) {
transport.etaTime = parsed.etaTime
transportFilled = true
} else if (isBlankString(transport.etaTime)) {
transport.etaTime = formatNowDatetime()
transportFilled = true
}
if (parsed.transportType === 'kdys' && isBlankString(transport.expressCompanyId)) {
const ec = matchExpressCompany(
parsed.transportModeText || '顺丰快递',
expressCompanyOptions.value
)
if (ec) {
transport.expressCompanyId = ec.id
if (!expressCompanyOptions.value.some((x) => x.id === ec.id)) {
expressCompanyOptions.value = [ec, ...expressCompanyOptions.value]
}
transportFilled = true
} else {
warnings.push(`未找到快递公司「${parsed.transportModeText || ''}」`)
}
}
if (parsed.transportType === 'ryxd' && parsed.carrierName && isBlankString(transport.carrierName)) {
transport.carrierName = parsed.carrierName
transportFilled = true
}
sample.transport = transport
if (transportFilled) filledLabels.push('运输信息')
}
async function applySampleHintsFromImport(
parsed: ProtocolStandardImportParsed,
warnings: string[],
filledLabels: string[]
) {
let hints = parsed.sampleHints
if (!hints.length) {
const matched = matchCellSampleType(parsed.sampleTypeHeader, sampleTypeOptions.value)
if (matched) {
hints = [{ typeKey: 'hsc', typeName: matched.typeName }]
}
}
for (const hint of hints) {
const matchedType = matchSampleTypeByExactName(hint.typeName, sampleTypeOptions.value)
const sample = normalizeSampleRow({ sampleTypeId: '', sampleStatus: 'ycj' }, formData.samples.length)
formData.samples.push(sample)
if (matchedType) {
sample.sampleTypeId = matchedType.id
sample.storageYears = matchedType.storageYears ?? null
sample.originalPrice = matchedType.storagePrice ?? null
sample.price = matchedType.storagePrice ?? null
if (!sampleTypeOptions.value.some((x) => x.id === matchedType.id)) {
sampleTypeOptions.value = [matchedType, ...sampleTypeOptions.value]
}
filledLabels.push(hint.typeName)
} else {
warnings.push(`无法匹配样本类型「${hint.typeName}」`)
}
if (hint.sampleCode) {
const sc = await resolveSampleCodeByCode(hint.sampleCode)
if (sc) {
sample.sampleCodeId = sc.id
sample.sampleCode = sc.sampleCode
if (!sampleCodeOptions.value.some((x) => x.id === sc.id)) {
sampleCodeOptions.value = [sc, ...sampleCodeOptions.value]
}
filledLabels.push('样本编码')
} else {
warnings.push(`未找到样本编码「${hint.sampleCode}」`)
}
}
applyParsedTransportToSample(sample, parsed, warnings, filledLabels)
}
}
function syncFormSamplesAfterImport() {
formData.samples = formData.samples.map((s, i) =>
normalizeSampleRow(
{
...s,
transport: s.transport ? { ...s.transport } : undefined
},
i
)
)
hydrateSampleLookupOptions()
}
function applyPaymentFromImport(parsed: ProtocolStandardImportParsed, warnings: string[]) {
formData.payMode = parsed.payMode || 'dj'
lastConfirmedPayMode.value = formData.payMode as PayMode
if (parsed.payMode === 'fq') {
applyFqPaymentDefaults()
}
if (parsed.contractAmount != null) {
formData.contractAmount = parsed.contractAmount
}
const transferTypeCode = resolveTransferTypeCode(
parsed.transferTypeLabel || '',
transferTypeOptions.value
)
if (parsed.transferTypeLabel && !transferTypeCode) {
warnings.push(`无法匹配转账方式「${parsed.transferTypeLabel}」`)
}
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
formData.paymentDetails = []
const transfers = parsed.paymentTransfers.filter((t) => t.transferAmount > 0)
if (transfers.length) {
transfers.forEach((t, i) => {
formData.paymentDetails.push({
transferType: transferTypeCode,
dueAmount: t.transferAmount,
transferAmount: t.transferAmount,
transferTime: t.transferTime || defaultTime,
payStatus: 'dfj',
installmentDueDate: formData.payMode === 'dj' ? formData.lumpSumDeadline : '',
_expanded: i === 0,
attachments: []
})
})
} else if (formData.payMode === 'dj' && formData.contractAmount != null) {
addPaymentDetail()
const detail = formData.paymentDetails[0]
if (detail) {
detail.transferType = transferTypeCode
detail.dueAmount = formData.contractAmount
detail.transferAmount = formData.contractAmount
}
}
initPaymentDetailExpandFlags(formData.paymentDetails)
markPayDetailLocks(formData.paymentDetails)
if (!transfers.length && !(formData.payMode === 'dj' && formData.contractAmount != null)) {
syncPaymentDueAmounts()
}
if (parsed.contractAmount != null && transfers.length) {
const sum = transfers.reduce((acc, t) => acc + Number(t.transferAmount || 0), 0)
if (Math.abs(sum - Number(parsed.contractAmount)) > 0.01) {
warnings.push(
`转账合计 ${sum} 与合同金额 ${parsed.contractAmount} 不一致,请核对缴费明细`
)
}
}
}
async function handleStandardImport() {
const parsed = parseProtocolStandardImportText(standardImportText.value)
if (!parsed) {
ElMessage.warning('请粘贴有效的标准信息')
return
}
standardImportLoading.value = true
const warnings: string[] = []
const filledLabels: string[] = []
try {
clearFormBeforeStandardImport()
await ensureTransferTypeOptions()
await remoteSearchSampleTypes('')
const tasks: Promise<void>[] = []
if (parsed.motherName) {
tasks.push(
applyMotherByName(parsed.motherName).then((result) => {
if (result === 'linked') filledLabels.push('母亲信息(已关联客户)')
else if (result === 'name-only') filledLabels.push('母亲姓名')
})
)
}
if (parsed.hospitalName) {
tasks.push(
applyHospitalByName(parsed.hospitalName).then((ok) => {
if (ok) filledLabels.push('采集地区/采集医院')
else if (isBlankString(formData.hospitalId)) {
warnings.push(`未找到采集医院「${parsed.hospitalName}」`)
}
})
)
}
if (parsed.salesUserName) {
tasks.push(
applySalesUserByName(parsed.salesUserName).then((ok) => {
if (ok) filledLabels.push('采集专员/销售人员')
else warnings.push(`未找到销售人员「${parsed.salesUserName}」`)
})
)
}
const needsTransport = !!parsed.transportType
if (needsTransport) {
tasks.push(remoteSearchSites('').then(() => undefined))
if (parsed.transportType === 'kdys') {
tasks.push(
remoteSearchExpressCompanies(
(parsed.transportModeText || '').replace(/快递$/, '') || '顺丰'
).then(() => undefined)
)
}
}
await Promise.all(tasks)
if (parsed.motherPhone) {
formData.motherPhone = parsed.motherPhone
filledLabels.push('孕妇电话')
}
if (parsed.newbornSex) {
formData.newbornSex = parsed.newbornSex
filledLabels.push('新生儿性别')
}
if (parsed.newbornWeightGram != null) {
formData.newbornWeightGram = parsed.newbornWeightGram
filledLabels.push('新生儿体重')
}
if (parsed.apgarScore != null) {
formData.apgar1min = parsed.apgarScore
filledLabels.push('新生儿评分')
}
if (parsed.newbornDeliveryTime) {
formData.newbornDeliveryTime = parsed.newbornDeliveryTime
filledLabels.push('新生儿分娩时间')
}
if (parsed.sampleCollectTime) {
formData.sampleCollectTime = formatDatetimeToMinute(parsed.sampleCollectTime)
filledLabels.push('样本采集时间')
}
if (parsed.maternalBloodExplicitEmpty) {
formData.maternalBloodCollectTime = ''
filledLabels.push('母血采集时间')
} else if (parsed.maternalBloodCollectTime) {
formData.maternalBloodCollectTime = formatDatetimeToMinute(parsed.maternalBloodCollectTime)
filledLabels.push('母血采集时间')
}
if (parsed.deliveryMode) {
formData.deliveryMode = parsed.deliveryMode
filledLabels.push('分娩方式')
}
if (parsed.birthOrder != null) {
formData.birthOrder = parsed.birthOrder
filledLabels.push('胎次')
}
if (parsed.medicalStaff) {
formData.medicalStaff = parsed.medicalStaff
filledLabels.push('采集医护')
}
if (parsed.remark) {
formData.remark = parsed.remark
filledLabels.push('备注')
}
await applySampleHintsFromImport(parsed, warnings, filledLabels)
if (formData.samples.length) {
syncFormSamplesAfterImport()
}
applyPaymentFromImport(parsed, warnings)
if (parsed.contractAmount != null) {
filledLabels.push('合同金额')
const sampleTotal = formData.samples.reduce(
(sum, s) => sum + (Number(s.price) || 0),
0
)
if (sampleTotal > 0 && Math.abs(sampleTotal - Number(parsed.contractAmount)) > 0.01) {
warnings.push(
`样本优惠价合计 ${sampleTotal} 与合同金额 ${parsed.contractAmount} 不一致,请核对样本价格`
)
}
}
if (parsed.transportModeText && !parsed.transportType) {
warnings.push(`无法识别运输方式「${parsed.transportModeText}」`)
}
const uniqueFilled = [...new Set(filledLabels)]
if (uniqueFilled.length) {
ElMessage.success(`已填充:${uniqueFilled.join('、')}`)
} else if (!warnings.length) {
ElMessage.info('没有可填充的字段')
}
if (warnings.length) {
ElMessage.warning(warnings.join(';'))
}
} catch {
ElMessage.error('导入失败,请检查标准信息格式')
} finally {
standardImportLoading.value = false
}
}
/* ====== 客户检索(母亲信息) ====== */
function formatCustomerLabel(c: { customerName: string; phoneNo: string; idCardNo: string }) {
const parts = [c.customerName || '']
@@ -2554,6 +3117,20 @@
handleFqParamsChange()
}
/** 缴费明细应缴金额变更:分期模式下回写分期金额,保持与主表一致 */
function onPaymentDetailDueAmountChange(dIdx: number) {
if (formData.payMode !== 'fq') return
const detail = formData.paymentDetails[dIdx]
if (!detail || isPayDetailLocked(detail)) return
const installmentNo = Number(detail.installmentNo)
const ratioIdx = installmentNo >= 1 ? installmentNo - 1 : dIdx
if (!formData.installmentRatios) formData.installmentRatios = []
while (formData.installmentRatios.length <= ratioIdx) {
formData.installmentRatios.push(0)
}
formData.installmentRatios[ratioIdx] = Number(detail.dueAmount) || 0
}
const installmentAmountsError = computed(() => {
const amounts = formData.installmentRatios || []
if (!amounts.length || formData.contractAmount == null) return ''
@@ -2867,6 +3444,29 @@
&--green {
background: #f0fdf4;
}
&--import {
margin-bottom: 0;
}
}
.import-actions {
display: flex;
justify-content: flex-end;
padding: 0 0 8px;
}
.form-tab-pane--import-basic {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 16px;
align-items: stretch;
margin-bottom: 28px;
.section-card {
margin-bottom: 0;
height: 100%;
}
}
.section-title {
@@ -3318,6 +3918,10 @@
RESPONSIVE
═══════════════════════════════════════════ */
@media (max-width: 900px) {
.form-tab-pane--import-basic {
grid-template-columns: 1fr;
}
.form-grid {
grid-template-columns: repeat(2, 1fr);