Browse Source

feat: 移动端和小程序的协议单、运费信息在选择顺丰时改为月结并填写单号

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 2 weeks ago
parent
commit
9b34f9e1ba

+ 1 - 0
mobile-oa-h5/src/market/components/MarketFormField.vue

@@ -75,6 +75,7 @@
       @upload="$emit('upload', $event)"
       @remove="removeAttachment"
     />
+    <div v-else-if="field.type === 'static'" class="market-form-field__static">{{ field.staticText }}</div>
     <van-field
       v-else
       :id="controlId"

+ 1 - 0
mobile-oa-h5/src/market/components/MarketFormSection.vue

@@ -67,6 +67,7 @@ const visibleFields = computed(() => (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')
 ))
 

+ 31 - 19
mobile-oa-h5/src/market/components/ProtocolSampleTransportPanel.vue

@@ -91,7 +91,7 @@
       >
         <template v-if="freightDisplay(row).modeLabel">
           {{ freightDisplay(row).modeLabel }}
-          · {{ formatMoney(freightDisplay(row).amount) }}{{ freightDisplay(row).shared ? '(共用)' : '' }}
+          · {{ freightDisplay(row).amountLabel }}{{ freightDisplay(row).shared ? '(共用)' : '' }}
         </template>
         <template v-else>未配置运费 — 勾选后点「配置运费」</template>
       </div>
@@ -248,7 +248,20 @@
                 />
               </div>
             </div>
-            <div class="market-form-field">
+            <div v-if="freightForm.transportMode === 'sfkd'" class="market-form-field">
+              <label>运费金额</label>
+              <div class="market-form-field__control">月结</div>
+            </div>
+            <div v-if="freightForm.transportMode === 'sfkd'" class="market-form-field">
+              <label>顺丰单号<b>*</b></label>
+              <van-field
+                v-model="freightForm.waybillNo"
+                maxlength="64"
+                placeholder="请输入顺丰单号"
+                :border="false"
+              />
+            </div>
+            <div v-else class="market-form-field">
               <label>运费金额<b>*</b></label>
               <div class="market-form-field__control">
                 <OaMoneyField
@@ -373,6 +386,7 @@ import {
   applySampleTypeDefaults,
   buildFreightCreatePayload,
   buildFreightDraft,
+  buildFreightItemBodies,
   buildFreightUnlinkCandidates,
   emptySampleRow,
   formatFreightCreatorName,
@@ -432,7 +446,7 @@ function resetPopupScroll(popupRef) {
 const freightVisible = ref(false)
 const freightMode = ref('create')
 const freightBusy = ref(false)
-const freightForm = reactive({ transportMode: '', freightAmount: '' })
+const freightForm = reactive({ transportMode: '', freightAmount: '', waybillNo: '' })
 const selectedFreightId = ref('')
 const existingFreights = ref([])
 const existingFreightKeyword = ref('')
@@ -650,6 +664,7 @@ async function openFreightConfig() {
   freightMode.value = 'create'
   freightForm.transportMode = ''
   freightForm.freightAmount = ''
+  freightForm.waybillNo = ''
   selectedFreightId.value = ''
   existingFreightKeyword.value = ''
   freightVisible.value = true
@@ -667,7 +682,8 @@ async function loadExistingFreights() {
       sampleSource: props.source || undefined,
       keyword: existingFreightKeyword.value.trim() || undefined
     })
-    existingFreights.value = data?.list || data?.records || data?.items || data?.rows || []
+    const list = data?.list || data?.records || data?.items || data?.rows || []
+    existingFreights.value = list.filter((item) => 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)
         }
       })
     }

+ 2 - 0
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) {

+ 28 - 0
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' }] },

+ 11 - 3
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'])
   }
 }
 

+ 12 - 0
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, [])
+})

+ 11 - 2
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' }
     ]
   }]
 }

+ 8 - 3
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 ? '(共用)' : ''}`
                 }
               }
             ],

+ 48 - 10
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

+ 52 - 1
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,
+    '月结'
+  )
+})

+ 6 - 0
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(() => {

+ 11 - 0
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