feat: 网页配置顺丰运费时改为月结并填写顺丰单号

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-21 19:20:43 +08:00
1 parent afcb4e9ebe
commit 9561f94074
7 files changed
+312 -98

No files matched your search

+8 -3
View File
@@ -18,7 +18,11 @@ export type SampleFreight = {
freightNo?: string
transportMode: string
transportModeLabel?: string
freightAmount: number
freightAmount: number | null
waybillNo?: string
settleStatus?: string
settleTime?: string
freightAmountLabel?: string
linkedSampleCount?: number
createBy?: string
createByName?: string
@@ -63,7 +67,8 @@ export function fetchSampleFreightDetail(id: string) {
export function fetchCreateSampleFreight(data: {
transportMode: string
freightAmount: number
freightAmount: number | null
waybillNo?: string
items: SampleFreightItem[]
}) {
return request.post<SampleFreight>({
@@ -75,7 +80,7 @@ export function fetchCreateSampleFreight(data: {
export function fetchUpdateSampleFreight(
id: string,
data: { transportMode?: string; freightAmount?: number }
data: { transportMode?: string; freightAmount?: number | null; waybillNo?: string }
) {
return request.put<SampleFreight>({
url: `/web/admin/sample-freights/${id}`,
@@ -6,19 +6,11 @@
<el-icon><Plus /></el-icon>
添加样本
</el-button>
<el-button
class="btn-ghost"
:disabled="!selectedSamples.length"
@click="openFreightDialog"
>
<el-button class="btn-ghost" :disabled="!selectedSamples.length" @click="openFreightDialog">
<el-icon><Van /></el-icon>
配置运费
</el-button>
<el-button
class="btn-ghost"
:disabled="!canUnlinkFreight"
@click="openUnlinkFreightDialog"
>
<el-button class="btn-ghost" :disabled="!canUnlinkFreight" @click="openUnlinkFreightDialog">
<el-icon><Delete /></el-icon>
取消运费
</el-button>
@@ -104,12 +96,7 @@
<el-table-column label="运输方式" min-width="120">
<template #default="{ row }">{{ freightModeLabel(row) }}</template>
</el-table-column>
<el-table-column
label="运费(元)"
min-width="120"
align="right"
header-align="center"
>
<el-table-column label="运费(元)" min-width="120" align="right" header-align="center">
<template #default="{ row }">{{ freightAmountText(row) }}</template>
</el-table-column>
<el-table-column label="预计到达" min-width="160" show-overflow-tooltip>
@@ -209,11 +196,7 @@
</el-select>
</el-form-item>
<el-form-item v-if="showPrice" label="原价格(元)">
<el-input
:model-value="fmtMoney(sampleDraft.originalPrice)"
disabled
placeholder="—"
/>
<el-input :model-value="fmtMoney(sampleDraft.originalPrice)" disabled placeholder="—" />
</el-form-item>
<el-form-item v-if="showPrice" label="优惠价格(元)">
<AmountInput v-model="sampleDraft.price" :min="0" placeholder="请输入优惠价格" />
@@ -318,9 +301,25 @@
/>
</el-select>
</el-form-item>
<el-form-item label="运费(元)">
<el-form-item v-if="!isSfExpress(freightForm.transportMode)" label="运费(元)">
<AmountInput v-model="freightForm.freightAmount" :min="0" placeholder="请输入运费" />
</el-form-item>
<el-form-item v-else label="运费(元)">
<el-input model-value="月结" readonly />
</el-form-item>
<el-form-item
v-if="isSfExpress(freightForm.transportMode)"
label="顺丰单号"
required
class="form-col-span-2"
>
<el-input
v-model="freightForm.waybillNo"
maxlength="64"
clearable
placeholder="请输入顺丰单号"
/>
</el-form-item>
</div>
</el-form>
<div v-else class="link-existing-block">
@@ -382,6 +381,12 @@
} from '@/components/business/freight-unlink-dialog/index.vue'
import { sampleStatusLabel, sampleStatusTagType } from '@/utils/status-tag-registry'
import { formatMoneyDisplay } from '@/utils/money-format'
import {
buildFreightHeaderPayload,
freightAmountText as formatFreightAmountText,
isSfExpress,
validateFreightHeader
} from '@/utils/sf-express-freight'
import {
partitionFreightConfigSamples,
freightConfigSkipMessage,
@@ -466,7 +471,8 @@
const freightSubmitting = ref(false)
const freightForm = reactive({
transportMode: '' as string,
freightAmount: null as number | null
freightAmount: null as number | null,
waybillNo: ''
})
const transportModeOptions = ref<Array<{ label: string; value: string }>>([])
const existingFreightList = ref<SampleFreight[]>([])
@@ -487,9 +493,7 @@
const sampleDialogIndex = ref(-1)
const sampleDraft = reactive<Record<string, any>>({})
const canUnlinkFreight = computed(() =>
selectedSamples.value.some((r) => sampleHasFreight(r))
)
const canUnlinkFreight = computed(() => selectedSamples.value.some((r) => sampleHasFreight(r)))
function fmtMoney(v: unknown): string {
if (v == null || v === '') return '—'
@@ -506,11 +510,12 @@
}
function freightAmountText(row: Record<string, any>) {
const mode = row.freight?.transportMode || row.freightDraft?.transportMode
const amount = row.freight?.freightAmount ?? row.freightDraft?.freightAmount
if (amount == null || amount === '') return '—'
const base = fmtMoney(amount)
const text = formatFreightAmountText(mode, amount)
if (!text) return '—'
const n = Number(row.freight?.linkedSampleCount || 0)
return n > 1 ? `${base}(共用)` : base
return n > 1 ? `${text}(共用)` : text
}
function normalizeSampleRow(raw: Record<string, any>, _index: number) {
@@ -603,8 +608,7 @@
if (currentId && sc.id === currentId) return true
if (sc.usedInProtocol) return false
return !props.samples.some(
(s, i) =>
(editingIndex < 0 || i !== editingIndex) && String(s.sampleCodeId || '') === sc.id
(s, i) => (editingIndex < 0 || i !== editingIndex) && String(s.sampleCodeId || '') === sc.id
)
})
})
@@ -693,16 +697,21 @@
? {
id: freight.id,
transportMode: freight.transportMode,
transportModeLabel: freight.transportModeLabel || transportModeLabelOf(String(freight.transportMode || '')),
transportModeLabel:
freight.transportModeLabel ||
transportModeLabelOf(String(freight.transportMode || '')),
freightAmount: freight.freightAmount,
waybillNo: freight.waybillNo,
linkedSampleCount: freight.linkedSampleCount
}
: {
id: '',
transportMode: freight.transportMode,
transportModeLabel:
freight.transportModeLabel || transportModeLabelOf(String(freight.transportMode || '')),
freight.transportModeLabel ||
transportModeLabelOf(String(freight.transportMode || '')),
freightAmount: freight.freightAmount,
waybillNo: freight.waybillNo,
linkedSampleCount: freight.linkedSampleCount
},
freightId: freight.id || '',
@@ -764,7 +773,9 @@
keyword: existingFreightKeyword.value || undefined
})
const data = (res as any)?.data || res
existingFreightList.value = data?.list || []
existingFreightList.value = ((data?.list || []) as SampleFreight[]).filter(
(row) => row.settleStatus !== 'yjs'
)
} catch {
existingFreightList.value = []
} finally {
@@ -789,6 +800,7 @@
freightMode.value = 'create'
freightForm.transportMode = ''
freightForm.freightAmount = null
freightForm.waybillNo = ''
selectedExistingFreight.value = null
selectedFreightId.value = ''
existingFreightKeyword.value = ''
@@ -813,39 +825,52 @@
freightSubmitting.value = true
try {
if (freightMode.value === 'create') {
if (!freightForm.transportMode || freightForm.freightAmount == null) {
ElMessage.warning('请填写运输方式与运费')
return
}
const amount = Number(freightForm.freightAmount)
if (Number.isNaN(amount) || amount < 0) {
ElMessage.warning('运费须为不小于 0 的数字')
const message = validateFreightHeader({
transportMode: freightForm.transportMode,
freightAmount: freightForm.freightAmount,
waybillNo: freightForm.waybillNo
})
if (message) {
ElMessage.warning(message)
return
}
const header = buildFreightHeaderPayload({
transportMode: freightForm.transportMode,
freightAmount: freightForm.freightAmount,
waybillNo: freightForm.waybillNo
})
let created: SampleFreight | null = null
if (persisted.length) {
const res = await fetchCreateSampleFreight({
transportMode: freightForm.transportMode,
freightAmount: amount,
transportMode: header.transportMode,
freightAmount: header.freightAmount,
waybillNo: header.waybillNo,
items: toFreightItems(persisted)
})
created = ((res as any)?.data || res) as SampleFreight
applyFreightToRows(persisted, created)
applyFreightToRows(persisted, {
...created,
transportMode: header.transportMode,
freightAmount: header.freightAmount,
waybillNo: header.waybillNo
})
}
if (drafts.length) {
const clientKey = `fd_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
applyFreightToRows(drafts, {
id: created?.id || '',
transportMode: freightForm.transportMode,
transportModeLabel: transportModeLabelOf(freightForm.transportMode),
freightAmount: amount,
transportMode: header.transportMode,
transportModeLabel: transportModeLabelOf(header.transportMode),
freightAmount: header.freightAmount,
waybillNo: header.waybillNo,
linkedSampleCount: (created?.linkedSampleCount || 0) + drafts.length,
freightDraft: created?.id
? undefined
: {
clientKey,
transportMode: freightForm.transportMode,
freightAmount: amount
transportMode: header.transportMode,
waybillNo: header.waybillNo,
freightAmount: header.freightAmount
}
})
}
@@ -907,11 +932,11 @@
if (!persistedSeeds.length && draftOnly.length) {
try {
await ElMessageBox.confirm(
'确定取消所选样本的运费吗?取消后可再次配置运费。',
'取消运费',
{ type: 'warning', confirmButtonText: '确定', cancelButtonText: '取消' }
)
await ElMessageBox.confirm('确定取消所选样本的运费吗?取消后可再次配置运费。', '取消运费', {
type: 'warning',
confirmButtonText: '确定',
cancelButtonText: '取消'
})
} catch {
return
}
@@ -922,9 +947,7 @@
const freightIds = [
...new Set(
persistedSeeds
.map((r) => String(r.freight?.id || r.freightId || '').trim())
.filter(Boolean)
persistedSeeds.map((r) => String(r.freight?.id || r.freightId || '').trim()).filter(Boolean)
)
]
const byKey = new Map<string, FreightUnlinkRow>()
@@ -1077,8 +1100,7 @@
return
}
const dup = props.samples.some(
(s, i) =>
i !== sampleDialogIndex.value && String(s.sampleCode || '').trim() === code
(s, i) => i !== sampleDialogIndex.value && String(s.sampleCode || '').trim() === code
)
if (dup) {
ElMessage.warning('样本编码重复,请重新添加')
@@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
buildFreightHeaderPayload,
buildSettlePayload,
freightAmountText,
validateFreightHeader
} from './sf-express-freight.ts'
test('sf express hides amount and requires waybill', () => {
assert.equal(
validateFreightHeader({ transportMode: 'sfkd', freightAmount: null, waybillNo: ' SF1 ' }),
''
)
assert.match(
validateFreightHeader({ transportMode: 'sfkd', freightAmount: null, waybillNo: ' ' }),
/请填写顺丰单号/
)
assert.deepEqual(
buildFreightHeaderPayload({ transportMode: 'sfkd', freightAmount: 8, waybillNo: ' SF1 ' }),
{ transportMode: 'sfkd', waybillNo: 'SF1', freightAmount: null }
)
assert.equal(freightAmountText('sfkd', null), '月结')
assert.equal(freightAmountText('sfkd', 8), '8.00')
})
test('other transport still requires amount', () => {
assert.match(
validateFreightHeader({ transportMode: 'zxc', freightAmount: null, waybillNo: '' }),
/请填写运费/
)
assert.equal(
buildFreightHeaderPayload({ transportMode: 'zxc', freightAmount: 12.3, waybillNo: 'SF1' })
.freightAmount,
12.3
)
assert.equal(
buildFreightHeaderPayload({ transportMode: 'zxc', freightAmount: 12.3, waybillNo: 'SF1' })
.waybillNo,
''
)
})
test('blank settle omits amount and batch settle never sends amount', () => {
assert.deepEqual(buildSettlePayload(['a'], ' '), { ids: ['a'] })
assert.deepEqual(buildSettlePayload(['a'], '6.5'), { ids: ['a'], freightAmount: 6.5 })
assert.deepEqual(buildSettlePayload(['a', 'b'], '6.5'), { ids: ['a', 'b'] })
})
+59
View File
@@ -0,0 +1,59 @@
export const SF_TRANSPORT_MODE = 'sfkd'
export function isSfExpress(mode: string | null | undefined) {
return String(mode || '').trim() === SF_TRANSPORT_MODE
}
export function freightAmountText(
mode: string | null | undefined,
amount: number | string | null | undefined
) {
if (isSfExpress(mode) && (amount === null || amount === undefined || amount === '')) return '月结'
if (amount === null || amount === undefined || amount === '') return ''
const value = Number(amount)
if (!Number.isFinite(value)) return ''
return value.toFixed(2)
}
export function validateFreightHeader(input: {
transportMode: string
freightAmount: number | null
waybillNo: string
}) {
if (!String(input.transportMode || '').trim()) return '请选择运输方式'
if (isSfExpress(input.transportMode)) {
const waybill = String(input.waybillNo || '').trim()
if (!waybill) return '请填写顺丰单号'
if (waybill.length > 64) return '顺丰单号不能超过 64 个字符'
return ''
}
if (
input.freightAmount == null ||
!Number.isFinite(Number(input.freightAmount)) ||
Number(input.freightAmount) < 0
) {
return '请填写运费'
}
return ''
}
export function buildFreightHeaderPayload(input: {
transportMode: string
freightAmount: number | null
waybillNo: string
}) {
const sf = isSfExpress(input.transportMode)
return {
transportMode: String(input.transportMode || '').trim(),
waybillNo: sf ? String(input.waybillNo || '').trim() : '',
freightAmount: sf ? null : Number(input.freightAmount)
}
}
export function buildSettlePayload(ids: string[], freightAmount: string) {
const payload: { ids: string[]; freightAmount?: number } = { ids }
if (ids.length === 1 && String(freightAmount || '').trim()) {
payload.freightAmount = Number(String(freightAmount).trim())
}
return payload
}
@@ -36,9 +36,25 @@
/>
</el-select>
</el-form-item>
<el-form-item label="运费(元)" required>
<el-form-item v-if="!isSfExpress(form.transportMode)" label="运费(元)" required>
<AmountInput v-model="form.freightAmount" :min="0" placeholder="请输入运费" />
</el-form-item>
<el-form-item v-else label="运费(元)">
<el-input model-value="月结" readonly />
</el-form-item>
<el-form-item
v-if="isSfExpress(form.transportMode)"
label="顺丰单号"
required
class="form-col-span-2"
>
<el-input
v-model="form.waybillNo"
maxlength="64"
clearable
placeholder="请输入顺丰单号"
/>
</el-form-item>
</div>
</el-form>
@@ -200,6 +216,11 @@
type SampleFreightItem
} from '@/api/sample-freight'
import { receptionSourceLabel, receptionSourceTagType } from '@/utils/status-tag-registry'
import {
buildFreightHeaderPayload,
isSfExpress,
validateFreightHeader
} from '@/utils/sf-express-freight'
defineOptions({ name: 'FreightConfigDialog' })
@@ -229,7 +250,8 @@
})
const form = reactive({
transportMode: '',
freightAmount: null as number | null
freightAmount: null as number | null,
waybillNo: ''
})
async function loadTransportModeOptions() {
@@ -258,7 +280,9 @@
keyword: existingKeyword.value || undefined
})
const data = (res as any)?.data || res
existingFreightList.value = data?.list || []
existingFreightList.value = ((data?.list || []) as SampleFreight[]).filter(
(row) => row.settleStatus !== 'yjs'
)
} catch {
existingFreightList.value = []
} finally {
@@ -316,6 +340,7 @@
mode.value = 'create'
form.transportMode = ''
form.freightAmount = null
form.waybillNo = ''
selectedExisting.value = null
selectedFreightId.value = ''
existingKeyword.value = ''
@@ -370,18 +395,24 @@
submitting.value = true
try {
if (mode.value === 'create') {
if (!form.transportMode || form.freightAmount == null) {
ElMessage.warning('请填写运输方式与运费')
return
}
const amount = Number(form.freightAmount)
if (Number.isNaN(amount) || amount < 0) {
ElMessage.warning('运费须为不小于 0 的数字')
return
}
await fetchCreateSampleFreight({
const message = validateFreightHeader({
transportMode: form.transportMode,
freightAmount: amount,
freightAmount: form.freightAmount,
waybillNo: form.waybillNo
})
if (message) {
ElMessage.warning(message)
return
}
const header = buildFreightHeaderPayload({
transportMode: form.transportMode,
freightAmount: form.freightAmount,
waybillNo: form.waybillNo
})
await fetchCreateSampleFreight({
transportMode: header.transportMode,
freightAmount: header.freightAmount,
waybillNo: header.waybillNo,
items: toItems(samples)
})
} else {
@@ -41,9 +41,25 @@
/>
</el-select>
</el-form-item>
<el-form-item label="运费(元)" prop="freightAmount">
<el-form-item v-if="!isSfExpress(form.transportMode)" label="运费(元)" required>
<AmountInput v-model="form.freightAmount" :min="0" placeholder="请输入运费" />
</el-form-item>
<el-form-item v-else label="运费(元)">
<el-input model-value="月结" readonly />
</el-form-item>
<el-form-item
v-if="isSfExpress(form.transportMode)"
label="顺丰单号"
required
class="form-col-span-2"
>
<el-input
v-model="form.waybillNo"
maxlength="64"
clearable
placeholder="请输入顺丰单号"
/>
</el-form-item>
</div>
</el-form>
<template #footer>
@@ -65,6 +81,11 @@
import AmountInput from '@/components/common/amount-input/index.vue'
import { fetchDictDataOptions } from '@/api/admin'
import { fetchUpdateSampleFreight, type SampleFreight } from '@/api/sample-freight'
import {
buildFreightHeaderPayload,
isSfExpress,
validateFreightHeader
} from '@/utils/sf-express-freight'
defineOptions({ name: 'FreightEditDialog' })
@@ -78,12 +99,12 @@
const transportModeOptions = ref<Array<{ label: string; value: string }>>([])
const form = reactive({
transportMode: '',
freightAmount: null as number | null
freightAmount: null as number | null,
waybillNo: ''
})
const rules: FormRules = {
transportMode: [{ required: true, message: '请选择运输方式', trigger: 'change' }],
freightAmount: [{ required: true, message: '请输入运费', trigger: 'blur' }]
transportMode: [{ required: true, message: '请选择运输方式', trigger: 'change' }]
}
async function loadTransportModeOptions() {
@@ -107,6 +128,7 @@
linkedCount.value = row.linkedSampleCount ?? row.items?.length ?? 0
form.transportMode = row.transportMode || ''
form.freightAmount = row.freightAmount ?? null
form.waybillNo = row.waybillNo || ''
await loadTransportModeOptions()
visible.value = true
}
@@ -115,21 +137,31 @@
formRef.value?.resetFields()
freightId.value = ''
linkedCount.value = 0
form.waybillNo = ''
}
async function handleSubmit() {
if (!formRef.value) return
await formRef.value.validate()
const amount = Number(form.freightAmount)
if (Number.isNaN(amount) || amount < 0) {
ElMessage.warning('运费须为不小于 0 的数字')
const message = validateFreightHeader({
transportMode: form.transportMode,
freightAmount: form.freightAmount,
waybillNo: form.waybillNo
})
if (message) {
ElMessage.warning(message)
return
}
const header = buildFreightHeaderPayload({
transportMode: form.transportMode,
freightAmount: form.freightAmount,
waybillNo: form.waybillNo
})
submitting.value = true
try {
await fetchUpdateSampleFreight(freightId.value, {
transportMode: form.transportMode,
freightAmount: amount
transportMode: header.transportMode,
waybillNo: header.waybillNo,
freightAmount: header.freightAmount
})
visible.value = false
emit('success')
@@ -33,7 +33,12 @@
</el-select>
</el-form-item>
<el-form-item label="样本来源">
<el-select v-model="searchForm.sampleSource" placeholder="全部" clearable style="width: 100%">
<el-select
v-model="searchForm.sampleSource"
placeholder="全部"
clearable
style="width: 100%"
>
<el-option label="细胞存储" value="cell_storage" />
<el-option label="科研捐赠" value="research_donation" />
</el-select>
@@ -100,7 +105,7 @@
min-width="48"
align="center"
header-align="center"
:selectable="(row: FlatRow) => row.isFirst"
:selectable="(row: FlatRow) => row.isFirst && row.freight.settleStatus !== 'yjs'"
/>
<el-table-column
prop="batchNo"
@@ -123,7 +128,11 @@
:type="transportTypeTagType(String(row.transportMode || ''))"
size="small"
>
{{ row.transportModeLabel || transportTypeLabel(String(row.transportMode || '')) || '—' }}
{{
row.transportModeLabel ||
transportTypeLabel(String(row.transportMode || '')) ||
'—'
}}
</el-tag>
<span v-else class="muted">—</span>
</template>
@@ -135,7 +144,12 @@
align="right"
header-align="center"
>
<template #default="{ row }">¥{{ formatMoneyDisplay(row.freightAmount) }}</template>
<template #default="{ row }">{{
freightAmountText(
row.transportMode,
row.freightAmount ?? row.freightDraft?.freightAmount
)
}}</template>
</el-table-column>
<el-table-column
prop="linkedSampleCount"
@@ -228,7 +242,7 @@
header-align="center"
>
<template #default="{ row }">
<div v-if="row.isFirst" class="op-row">
<div v-if="row.isFirst && row.freight.settleStatus !== 'yjs'" class="op-row">
<el-button class="btn-op-item" @click="openEditById(row.freightId)">
<el-icon><Edit /></el-icon>
编辑
@@ -289,7 +303,7 @@
type FreightUnlinkRow
} from '@/components/business/freight-unlink-dialog/index.vue'
import { formatDisplayOrDash } from '@/utils/datetime-display'
import { formatMoneyDisplay } from '@/utils/money-format'
import { freightAmountText } from '@/utils/sf-express-freight'
import {
receptionSourceLabel,
receptionSourceTagType,
@@ -429,11 +443,11 @@
const linked = Number(row.linkedSampleCount || 0)
const linkHint =
linked > 0 ? `将解除其全部 ${linked} 条关联样本,并删除该运费单。` : '将删除该运费单。'
ElMessageBox.confirm(
`确认删除运费批次「${batchNo}」吗?${linkHint}`,
'删除确认',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' }
)
ElMessageBox.confirm(`确认删除运费批次「${batchNo}」吗?${linkHint}`, '删除确认', {
type: 'warning',
confirmButtonText: '删除',
cancelButtonText: '取消'
})
.then(async () => {
await fetchDeleteSampleFreight(freightId)
await loadData()
@@ -441,7 +455,10 @@
.catch(() => undefined)
}
function toUnlinkRow(it: NonNullable<SampleFreight['items']>[number], freightId: string): FreightUnlinkRow {
function toUnlinkRow(
it: NonNullable<SampleFreight['items']>[number],
freightId: string
): FreightUnlinkRow {
return {
...it,
freightId,