item.settleStatus !== 'yjs')
} catch (error) {
existingFreights.value = []
showToast(marketErrorMessage(error, '加载运费批次失败'))
@@ -677,14 +693,10 @@ async function loadExistingFreights() {
}
function toFreightItems(rows) {
- return rows
- .filter((row) => row.id)
- .map((row) => ({
- sampleSource: props.source,
- sampleId: row.id,
- protocolId: props.protocolId || undefined,
- sampleCode: row.sampleCode || ''
- }))
+ return buildFreightItemBodies(
+ (rows || []).filter((row) => row.id),
+ props.source
+ )
}
function applyFreightLocal(targets, freightPatch) {
@@ -738,7 +750,8 @@ async function confirmFreight() {
: {
transportMode: freightForm.transportMode,
transportModeLabel: transportModeLabel(freightForm.transportMode),
- freightAmount: freightDraft.freightAmount
+ freightAmount: freightDraft.freightAmount,
+ waybillNo: freightDraft.waybillNo
},
freightDraft: created?.id ? undefined : freightDraft
}
@@ -807,6 +820,10 @@ function toggleUnlinkKey(rowKey) {
}
async function unlinkFreight() {
+ if (selectedRows().some((row) => row?.freight?.settleStatus === 'yjs')) {
+ showToast('已结算的运费不能调整样本')
+ return
+ }
const selected = selectedRows().filter((row) => sampleHasFreight(row))
if (!selected.length) {
showToast('请先选择已配置运费的样本')
@@ -880,12 +897,7 @@ async function confirmUnlink() {
await performMarketAction(freightContext.value, 'unlink', {
id: freightId,
data: {
- items: rows.map((row) => ({
- sampleSource: row.sampleSource,
- sampleId: row.sampleId,
- protocolId: row.protocolId || props.protocolId || undefined,
- sampleCode: row.sampleCode || ''
- }))
+ items: buildFreightItemBodies(rows, props.source)
}
})
}
diff --git a/mobile-oa-h5/src/market/form-payload.js b/mobile-oa-h5/src/market/form-payload.js
index 54d8c77a..dfcd7cb0 100644
--- a/mobile-oa-h5/src/market/form-payload.js
+++ b/mobile-oa-h5/src/market/form-payload.js
@@ -23,6 +23,7 @@ export function sanitizeMarketPayload(value) {
function pickFields(fields, values, businessSource) {
const result = {}
for (const field of fields || []) {
+ if (field.type === 'static') continue
if (field.source && field.source !== businessSource) continue
if (Object.prototype.hasOwnProperty.call(values || {}, field.key)) {
result[field.key] = field.type === 'upload'
@@ -179,6 +180,7 @@ export function validateRequiredFields(sections, formData) {
: [section.nested ? formData?.[section.key] || {} : formData || {}]
for (const row of values) {
for (const field of section.fields || []) {
+ if (typeof field.showWhen === 'function' && field.showWhen(row) === false) continue
const value = row?.[field.key]
const missing = field.type === 'checkbox' ? value !== true : empty(value)
if (field.required && (!field.source || field.source === formData?.source) && missing) {
diff --git a/mobile-oa-h5/src/market/form-payload.test.js b/mobile-oa-h5/src/market/form-payload.test.js
index f207ce62..08d98d3a 100644
--- a/mobile-oa-h5/src/market/form-payload.test.js
+++ b/mobile-oa-h5/src/market/form-payload.test.js
@@ -9,6 +9,7 @@ import {
sanitizeMarketPayload,
validateRequiredFields
} from './form-payload.js'
+import { getMarketModule } from './module-registry.js'
test('mergeProtocolSamplePayments attaches only for cell_storage', () => {
const pays = [{
@@ -210,6 +211,33 @@ test('action payloads exclude fields rejected by strict backend DTOs', () => {
)
})
+test('sf express freight form requires a waybill and skips an empty amount', () => {
+ const sections = getMarketModule('freight').formSections
+ const missingWaybill = validateRequiredFields(sections, {
+ transportMode: 'sfkd',
+ freightAmount: '',
+ waybillNo: ''
+ })
+ assert.equal(missingWaybill, '请填写顺丰单号')
+ assert.doesNotMatch(missingWaybill, /请填写运费金额/)
+ assert.equal(
+ validateRequiredFields(sections, {
+ transportMode: 'sfkd',
+ freightAmount: '',
+ waybillNo: 'SF1'
+ }),
+ ''
+ )
+ assert.equal(
+ validateRequiredFields(sections, {
+ transportMode: 'zxc',
+ freightAmount: '',
+ waybillNo: ''
+ }),
+ '请填写运费金额'
+ )
+})
+
test('keeps only configured writable fields including nested and repeated sections', () => {
const sections = [
{ key: 'base', fields: [{ key: 'name' }] },
diff --git a/mobile-oa-h5/src/market/list-presenter.js b/mobile-oa-h5/src/market/list-presenter.js
index 585f0dc3..73cb9b4e 100644
--- a/mobile-oa-h5/src/market/list-presenter.js
+++ b/mobile-oa-h5/src/market/list-presenter.js
@@ -321,6 +321,13 @@ function freightTransportDisplay(item) {
return resolveStatusTag(transportType, code).label
}
+function freightMoneyLabel(item) {
+ if (String(item?.transportMode || '') === 'sfkd' && (item?.freightAmount == null || item?.freightAmount === '')) {
+ return '月结'
+ }
+ return money(item?.freightAmount)
+}
+
function freightCard(role, item) {
const rows = Array.isArray(item.items) ? item.items : []
const first = rows[0] || {}
@@ -329,14 +336,15 @@ function freightCard(role, item) {
const transportMode = freightTransportDisplay(item)
const source = resolveStatusTag(receptionSource, first.sampleSource || item.sourceLabel)
const sampleCount = Number(item.visibleItemCount ?? rows.length) || 0
+ const amountLabel = freightMoneyLabel(item)
return {
title: text(item.freightNo),
- subtitle: `${transportMode} · ${money(item.freightAmount)}`,
+ subtitle: `${transportMode} · ${amountLabel}`,
status: source.label,
statusTone: source.tagType,
fields: [
field('运输方式', transportMode, { tag: true, tagType: transportTag.tagType }),
- field('运费金额', money(item.freightAmount), { money: true }),
+ field('运费金额', amountLabel, { money: true }),
field('关联数', String(sampleCount)),
field('创建人', personName(item.creatorName || item.createByName, item.createBy)),
field('创建时间', item.createTime),
@@ -346,7 +354,7 @@ function freightCard(role, item) {
sampleBlocks: rows.map(freightSampleBlock),
sampleCount,
privacyNote: '',
- actions: allowed(item, ['edit', 'delete', 'link', 'unlink'])
+ actions: item.settleStatus === 'yjs' ? [] : allowed(item, ['edit', 'delete', 'link', 'unlink'])
}
}
diff --git a/mobile-oa-h5/src/market/list-presenter.test.js b/mobile-oa-h5/src/market/list-presenter.test.js
index f41cab8c..f3d48337 100644
--- a/mobile-oa-h5/src/market/list-presenter.test.js
+++ b/mobile-oa-h5/src/market/list-presenter.test.js
@@ -290,3 +290,15 @@ test('list cards hide unresolved reference and dictionary codes while retaining
assert.equal(payment.fields.find((field) => field.label === '样本编码').value, 'S-KEEP-2')
assert.equal(payment.fields.find((field) => field.label === '缴费方式').value, '未知')
})
+
+test('sf express freight card shows monthly settlement and hides settled actions', () => {
+ const card = buildMarketCard('freight', 'specialist', {
+ freightNo: 'YF-SF',
+ transportMode: 'sfkd',
+ freightAmount: null,
+ settleStatus: 'yjs',
+ allowedActions: ['edit', 'delete', 'link', 'unlink']
+ })
+ assert.ok(card.subtitle.includes('月结'))
+ assert.deepEqual(card.actions, [])
+})
diff --git a/mobile-oa-h5/src/market/modules/freight.js b/mobile-oa-h5/src/market/modules/freight.js
index f5bee047..8418ea42 100644
--- a/mobile-oa-h5/src/market/modules/freight.js
+++ b/mobile-oa-h5/src/market/modules/freight.js
@@ -26,6 +26,13 @@ function money(value) {
return `¥${amount.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
+function freightMoneyLabel(item) {
+ if (String(item?.transportMode || '') === 'sfkd' && (item?.freightAmount == null || item?.freightAmount === '')) {
+ return '月结'
+ }
+ return money(item?.freightAmount)
+}
+
export default {
key: 'freight',
resource: 'freights',
@@ -63,7 +70,7 @@ export default {
tag: true,
tagType: transportTag.tagType
},
- { label: '运费金额', value: money(detail?.freightAmount) },
+ { label: '运费金额', value: freightMoneyLabel(detail) },
{ label: '关联数', value: String(detail?.visibleItemCount ?? detail?.items?.length ?? 0) },
{ label: '创建人', value: person(detail?.creatorName || detail?.createByName, detail?.createBy) },
{ label: '创建时间', value: detail?.createTime || '—' },
@@ -114,7 +121,9 @@ export default {
fieldsLayout: 'grid-2',
fields: [
{ key: 'transportMode', label: '运输方式', type: 'select', optionKey: 'transportModes', required: true },
- { key: 'freightAmount', label: '运费金额', type: 'number', required: true }
+ { key: 'monthlySettlement', label: '运费金额', type: 'static', staticText: '月结', showWhen: (row) => row?.transportMode === 'sfkd' },
+ { key: 'waybillNo', label: '顺丰单号', required: true, showWhen: (row) => row?.transportMode === 'sfkd' },
+ { key: 'freightAmount', label: '运费金额', type: 'number', required: true, showWhen: (row) => row?.transportMode !== 'sfkd' }
]
}]
}
diff --git a/mobile-oa-h5/src/market/modules/protocol.js b/mobile-oa-h5/src/market/modules/protocol.js
index 86d4eba9..c1c49a56 100644
--- a/mobile-oa-h5/src/market/modules/protocol.js
+++ b/mobile-oa-h5/src/market/modules/protocol.js
@@ -513,9 +513,14 @@ export default {
'运费金额',
{
map: () => {
- if (item?.freight?.freightAmount == null) return '—'
- const shared = Number(item.freight.linkedSampleCount || 0) > 1
- return `${item.freight.freightAmount}${shared ? '(共用)' : ''}`
+ const freight = item?.freight || {}
+ const shared = Number(freight.linkedSampleCount || 0) > 1
+ const amount = freight.freightAmount
+ if (String(freight.transportMode || '') === 'sfkd' && (amount == null || amount === '')) {
+ return `月结${shared ? '(共用)' : ''}`
+ }
+ if (amount == null) return '—'
+ return `${amount}${shared ? '(共用)' : ''}`
}
}
],
diff --git a/mobile-oa-h5/src/market/protocol-sample-transport.js b/mobile-oa-h5/src/market/protocol-sample-transport.js
index 30dd900e..10ac0f5a 100644
--- a/mobile-oa-h5/src/market/protocol-sample-transport.js
+++ b/mobile-oa-h5/src/market/protocol-sample-transport.js
@@ -1,7 +1,7 @@
/** 协议样本及运输:对齐 Web sample-transport-form-section 的纯函数 */
import { moneyInputText } from '../oa/money-field-model.js'
-import { compareMoney, normalizeMoney } from './money.js'
+import { compareMoney, formatMoney, normalizeMoney } from './money.js'
export const SAMPLE_STATUS_OPTIONS = Object.freeze([
{ label: '已采集', value: 'ycj' },
@@ -96,23 +96,37 @@ export function partitionFreightConfigSamples(samples) {
return { configurable, skipped }
}
-export function buildFreightDraft({ transportMode, freightAmount, clientKey } = {}) {
+export function buildFreightDraft({ transportMode, freightAmount, waybillNo, clientKey } = {}) {
+ const mode = String(transportMode || '').trim()
+ const sfExpress = mode === 'sfkd'
return {
clientKey: clientKey || `fd_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
- transportMode: String(transportMode || '').trim(),
- freightAmount: normalizeConstrainedMoney(freightAmount)
+ transportMode: mode,
+ freightAmount: sfExpress ? null : normalizeConstrainedMoney(freightAmount),
+ waybillNo: sfExpress ? String(waybillNo || '').trim() : ''
}
}
-export function buildFreightCreatePayload({ transportMode, freightAmount, items } = {}) {
- const draft = buildFreightDraft({ transportMode, freightAmount })
+export function buildFreightCreatePayload({ transportMode, freightAmount, waybillNo, items } = {}) {
+ const draft = buildFreightDraft({ transportMode, freightAmount, waybillNo })
return {
transportMode: draft.transportMode,
freightAmount: draft.freightAmount,
+ waybillNo: draft.waybillNo,
items: Array.isArray(items) ? items : []
}
}
+/** 运费关联只提交样本身份;协议与样本编码由服务端派生 */
+export function buildFreightItemBodies(rows, sampleSource) {
+ return (rows || [])
+ .map((row) => ({
+ sampleSource: String(row?.sampleSource || sampleSource || '').trim(),
+ sampleId: String(row?.sampleId || row?.id || '').trim()
+ }))
+ .filter((item) => item.sampleSource && item.sampleId)
+}
+
function freightTargetKey(row) {
return String(row?.id || row?._key || '').trim()
}
@@ -132,8 +146,15 @@ export function applyFreightAssignments(samples, assignments) {
})
}
-export function validateFreightForm({ transportMode, freightAmount } = {}) {
- if (!String(transportMode || '').trim()) return '请选择运输方式'
+export function validateFreightForm({ transportMode, freightAmount, waybillNo } = {}) {
+ const mode = String(transportMode || '').trim()
+ if (!mode) return '请选择运输方式'
+ if (mode === 'sfkd') {
+ const waybill = String(waybillNo || '').trim()
+ if (!waybill) return '请填写顺丰单号'
+ if (waybill.length > 64) return '顺丰单号不能超过 64 个字符'
+ return ''
+ }
const rawAmount = freightAmount == null ? '' : String(freightAmount).trim()
const amount = normalizeConstrainedMoney(rawAmount)
if (amount == null) {
@@ -142,9 +163,17 @@ export function validateFreightForm({ transportMode, freightAmount } = {}) {
return ''
}
+function freightAmountLabel(mode, amount) {
+ if (mode === 'sfkd' && (amount == null || amount === '')) return '月结'
+ if (amount == null || amount === '') return formatMoney(amount)
+ const formatted = formatMoney(amount)
+ return formatted && formatted !== '—' ? formatted : String(amount)
+}
+
export function freightDisplay(sample) {
const freight = sample?.freight || {}
const draft = sample?.freightDraft
+ const mode = String(freight.transportMode || draft?.transportMode || '').trim()
const modeLabel =
freight.transportModeLabel ||
freight.transportMode ||
@@ -158,7 +187,13 @@ export function freightDisplay(sample) {
: null
const linked = Number(freight.linkedSampleCount || 0)
const shared = linked > 1
- return { modeLabel, amount, shared, linkedSampleCount: linked }
+ return {
+ modeLabel,
+ amount,
+ amountLabel: freightAmountLabel(mode, amount),
+ shared,
+ linkedSampleCount: linked
+ }
}
/** 「关联已有」列表:去重样本编码,超过 max 个时截断为「前 max 个等 N 个」 */
@@ -268,10 +303,13 @@ export function serializeSamplesForSave(samples) {
const freightId = String(row.freightId || row.freight?.id || '').trim()
if (freightId) next.freightId = freightId
if (row.freightDraft && typeof row.freightDraft === 'object' && !freightId) {
+ const draftMode = String(row.freightDraft.transportMode || '').trim()
+ const sfExpress = draftMode === 'sfkd'
next.freightDraft = {
clientKey: row.freightDraft.clientKey,
transportMode: row.freightDraft.transportMode,
- freightAmount: normalizeConstrainedMoney(row.freightDraft.freightAmount)
+ freightAmount: sfExpress ? null : normalizeConstrainedMoney(row.freightDraft.freightAmount),
+ waybillNo: sfExpress ? String(row.freightDraft.waybillNo || '').trim() : ''
}
}
return next
diff --git a/mobile-oa-h5/src/market/protocol-sample-transport.test.js b/mobile-oa-h5/src/market/protocol-sample-transport.test.js
index 4091f530..7389eb7c 100644
--- a/mobile-oa-h5/src/market/protocol-sample-transport.test.js
+++ b/mobile-oa-h5/src/market/protocol-sample-transport.test.js
@@ -7,7 +7,9 @@ import { formatMoney } from './money.js'
import * as sampleTransport from './protocol-sample-transport.js'
import {
applySampleTypeDefaults,
+ buildFreightCreatePayload,
buildFreightDraft,
+ buildFreightItemBodies,
buildFreightUnlinkCandidates,
emptySampleRow,
formatFreightCreatorName,
@@ -152,6 +154,23 @@ test('freight partition skips already configured samples', () => {
assert.equal(sampleHasFreight({ freightId: 'x' }), true)
})
+test('freight item bodies keep only sample identity fields', () => {
+ const items = buildFreightItemBodies(
+ [
+ {
+ id: 'sample-1',
+ sampleCode: 'ZKZX34000199',
+ protocolId: 'protocol-1',
+ sampleSource: 'cell_storage'
+ }
+ ],
+ 'cell_storage'
+ )
+ assert.deepEqual(items, [{ sampleSource: 'cell_storage', sampleId: 'sample-1' }])
+ assert.equal('protocolId' in items[0], false)
+ assert.equal('sampleCode' in items[0], false)
+})
+
test('freight form validates the money input precision and range without float coercion', () => {
assert.match(validateFreightForm({}), /运输方式/)
for (const freightAmount of ['', '-1', 'abc', '0.001', '10000000000000000.00']) {
@@ -237,7 +256,7 @@ test('freightDisplay prefers persisted freight over draft and marks shared', ()
freight: { transportModeLabel: '专线车', freightAmount: 20, linkedSampleCount: 3 },
freightDraft: { transportMode: 'zxc', freightAmount: 1 }
}),
- { modeLabel: '专线车', amount: 20, shared: true, linkedSampleCount: 3 }
+ { modeLabel: '专线车', amount: 20, amountLabel: '¥20.00', shared: true, linkedSampleCount: 3 }
)
})
@@ -411,3 +430,35 @@ test('buildFreightUnlinkCandidates merges batch items and defaults to seeds', ()
assert.equal(items.length, 2)
assert.deepEqual(defaultSelectedKeys, ['cell_storage:s1'])
})
+
+test('sf express freight payload keeps waybill and omits amount', () => {
+ assert.match(
+ validateFreightForm({ transportMode: 'sfkd', freightAmount: '', waybillNo: '' }),
+ /请填写顺丰单号/
+ )
+ assert.equal(
+ validateFreightForm({ transportMode: 'sfkd', freightAmount: '', waybillNo: 'SF1' }),
+ ''
+ )
+ assert.deepEqual(
+ buildFreightCreatePayload({
+ transportMode: 'sfkd',
+ freightAmount: '8',
+ waybillNo: ' SF1 ',
+ items: [{ sampleId: 's1' }]
+ }).freightAmount,
+ null
+ )
+ const payload = buildFreightCreatePayload({
+ transportMode: 'sfkd',
+ freightAmount: '8',
+ waybillNo: ' SF1 ',
+ items: []
+ })
+ assert.equal(payload.waybillNo, 'SF1')
+ assert.equal(
+ freightDisplay({ freight: { transportMode: 'sfkd', freightAmount: null, linkedSampleCount: 2 } })
+ .amountLabel,
+ '月结'
+ )
+})
diff --git a/mobile-oa-h5/src/views/market/MarketModuleDetail.vue b/mobile-oa-h5/src/views/market/MarketModuleDetail.vue
index 36aa954f..1cb7c7d1 100644
--- a/mobile-oa-h5/src/views/market/MarketModuleDetail.vue
+++ b/mobile-oa-h5/src/views/market/MarketModuleDetail.vue
@@ -136,6 +136,12 @@ const allowedActions = computed(() => {
if (context.value.module === 'payment') {
return filterPaymentDetailBottomActions(raw)
}
+ if (context.value.module === 'freight' && detail.value?.settleStatus === 'yjs') {
+ return raw.filter((action) => {
+ const code = typeof action === 'string' ? action : action?.code
+ return !['edit', 'delete', 'link', 'unlink'].includes(code)
+ })
+ }
return raw
})
const sections = computed(() => {
diff --git a/mobile-oa-h5/src/views/market/MarketModuleForm.vue b/mobile-oa-h5/src/views/market/MarketModuleForm.vue
index 8d073108..9e6af9b4 100644
--- a/mobile-oa-h5/src/views/market/MarketModuleForm.vue
+++ b/mobile-oa-h5/src/views/market/MarketModuleForm.vue
@@ -511,6 +511,13 @@ function handleCustomerSelect({ section, field, customer }) {
}
async function handleFieldChange({ section, field, value, index }) {
+ if (context.value.module === 'freight' && field?.key === 'transportMode') {
+ formData.value = value === 'sfkd'
+ ? { ...formData.value, freightAmount: '' }
+ : { ...formData.value, waybillNo: '' }
+ dirty.value = true
+ return
+ }
if (context.value.module !== 'protocol') return
if (section.key === 'protocol' && field.key === 'source' && mode.value === 'create') {
const today = todayYmd()
@@ -992,6 +999,10 @@ function buildPayload() {
}
if (context.value.module === 'freight') {
const fields = pickConfiguredPayload(contract.value.formSections, formData.value)
+ if (String(fields.transportMode || '') === 'sfkd') {
+ fields.freightAmount = null
+ fields.waybillNo = String(fields.waybillNo ?? '').trim()
+ }
return ['create', 'link', 'unlink'].includes(action.value)
? { ...fields, items: selectedItems() }
: fields