From 9b34f9e1ba4a4ab60a25ca81ed24820e95ec2245 Mon Sep 17 00:00:00 2001 From: zlwu <704541015@qq.com> Date: Wed, 21 Oct 2026 19:49:57 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=A7=BB=E5=8A=A8=E7=AB=AF=E5=92=8C?= =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E7=9A=84=E5=8D=8F=E8=AE=AE=E5=8D=95?= =?UTF-8?q?=E3=80=81=E8=BF=90=E8=B4=B9=E4=BF=A1=E6=81=AF=E5=9C=A8=E9=80=89?= =?UTF-8?q?=E6=8B=A9=E9=A1=BA=E4=B8=B0=E6=97=B6=E6=94=B9=E4=B8=BA=E6=9C=88?= =?UTF-8?q?=E7=BB=93=E5=B9=B6=E5=A1=AB=E5=86=99=E5=8D=95=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- .../src/market/components/MarketFormField.vue | 1 + .../market/components/MarketFormSection.vue | 1 + .../ProtocolSampleTransportPanel.vue | 50 ++++++++++------ mobile-oa-h5/src/market/form-payload.js | 2 + mobile-oa-h5/src/market/form-payload.test.js | 28 +++++++++ mobile-oa-h5/src/market/list-presenter.js | 14 ++++- .../src/market/list-presenter.test.js | 12 ++++ mobile-oa-h5/src/market/modules/freight.js | 13 ++++- mobile-oa-h5/src/market/modules/protocol.js | 11 +++- .../src/market/protocol-sample-transport.js | 58 +++++++++++++++---- .../market/protocol-sample-transport.test.js | 53 ++++++++++++++++- .../src/views/market/MarketModuleDetail.vue | 6 ++ .../src/views/market/MarketModuleForm.vue | 11 ++++ 13 files changed, 222 insertions(+), 38 deletions(-) diff --git a/mobile-oa-h5/src/market/components/MarketFormField.vue b/mobile-oa-h5/src/market/components/MarketFormField.vue index 3c48be69..6d69e25a 100644 --- a/mobile-oa-h5/src/market/components/MarketFormField.vue +++ b/mobile-oa-h5/src/market/components/MarketFormField.vue @@ -75,6 +75,7 @@ @upload="$emit('upload', $event)" @remove="removeAttachment" /> +
{{ field.staticText }}
(props.section.fields || []).filter( (field) => !field.hidden && (!field.source || field.source === props.source) && + !(typeof field.showWhen === 'function' && field.showWhen(value.value) === false) && !(field.createOnly && props.mode !== 'create') )) diff --git a/mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue b/mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue index aaafe917..b05b24ac 100644 --- a/mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue +++ b/mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue @@ -91,7 +91,7 @@ > @@ -248,7 +248,20 @@ /> -
+
+ +
月结
+
+
+ + +
+
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