Просмотр исходного кода

feat(mobile-oa-h5): add ProtocolSamplePaymentPanel for mobile protocol forms

Mobile card UI for per-sample pay modes, schedules, and autofill rules.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 месяц назад
Родитель
Сommit
3354e66d82
1 измененных файлов с 501 добавлено и 0 удалено
  1. 501 0
      mobile-oa-h5/src/market/components/ProtocolSamplePaymentPanel.vue

+ 501 - 0
mobile-oa-h5/src/market/components/ProtocolSamplePaymentPanel.vue

@@ -0,0 +1,501 @@
+<template>
+  <section v-if="syncedPayments.length" class="market-form-section protocol-sample-pay">
+    <h2>缴费信息</h2>
+    <p class="protocol-sample-pay__hint">按样本配置缴费方案,规则与管理端一致</p>
+
+    <div
+      v-for="(pay, pIdx) in syncedPayments"
+      :key="pay.sampleCode || pIdx"
+      class="protocol-sample-pay__card"
+    >
+      <div class="protocol-sample-pay__head">
+        <strong>{{ sampleTitle(pay) }}</strong>
+        <span class="protocol-sample-pay__code">{{ pay.sampleCode || '—' }}</span>
+      </div>
+
+      <van-cell-group inset>
+        <van-field
+          :model-value="payModeLabel(pay.payMode)"
+          is-link
+          readonly
+          label="缴费模式"
+          placeholder="请选择"
+          :disabled="disabled"
+          @click="!disabled && openPayModeSheet(pIdx)"
+        />
+        <van-field
+          :model-value="pay.contractAmount == null ? '' : String(pay.contractAmount)"
+          type="number"
+          label="样本合同金额"
+          placeholder="请输入"
+          :disabled="disabled"
+          @update:model-value="(v) => onContractChange(pIdx, v)"
+        />
+        <template v-if="pay.payMode === 'dj'">
+          <van-field
+            :model-value="formatYmd(pay.lumpSumDeadline)"
+            is-link
+            readonly
+            label="趸交截止时间"
+            placeholder="选择日期"
+            :disabled="disabled"
+            @click="!disabled && openDatePicker(pIdx, 'lumpSumDeadline')"
+          />
+        </template>
+        <template v-else>
+          <van-field
+            :model-value="pay.installmentCount == null ? '' : String(pay.installmentCount)"
+            type="digit"
+            :label="pay.payMode === 'fgd' ? '期数' : '分期期数'"
+            :disabled="disabled"
+            @update:model-value="(v) => onFqField(pIdx, 'installmentCount', v)"
+            @blur="rebuildAt(pIdx)"
+          />
+          <van-field
+            :model-value="pay.installmentIntervalMonths == null ? '' : String(pay.installmentIntervalMonths)"
+            type="number"
+            label="每期间隔(月)"
+            :disabled="disabled"
+            @update:model-value="(v) => onFqField(pIdx, 'installmentIntervalMonths', v)"
+            @blur="rebuildAt(pIdx)"
+          />
+          <van-field
+            :model-value="formatYmd(pay.installmentStartDate)"
+            is-link
+            readonly
+            label="分期开始时间"
+            placeholder="选择日期"
+            :disabled="disabled"
+            @click="!disabled && openDatePicker(pIdx, 'installmentStartDate')"
+          />
+          <van-field
+            :model-value="pay.installmentAdvanceDays == null ? '' : String(pay.installmentAdvanceDays)"
+            type="digit"
+            label="提前催缴预警(天)"
+            :disabled="disabled"
+            @update:model-value="(v) => onFqField(pIdx, 'installmentAdvanceDays', v)"
+            @blur="rebuildAt(pIdx)"
+          />
+        </template>
+      </van-cell-group>
+
+      <div class="protocol-sample-pay__schedules">
+        <div class="protocol-sample-pay__sub">应缴批次</div>
+        <van-empty
+          v-if="!(pay.schedules || []).length"
+          description="请完善缴费参数后自动生成"
+          image-size="48"
+        />
+        <van-cell-group
+          v-for="(sch, sIdx) in pay.schedules || []"
+          :key="sch.id || sIdx"
+          inset
+          class="protocol-sample-pay__sch"
+        >
+          <div class="protocol-sample-pay__sch-title">第 {{ sch.scheduleNo || sIdx + 1 }} 期</div>
+          <van-field
+            :model-value="sch.dueAmount == null ? '' : String(sch.dueAmount)"
+            type="number"
+            label="应缴费金额"
+            :disabled="disabled || isScheduleLocked(sch)"
+            @update:model-value="(v) => onDueAmountChange(pIdx, sIdx, v)"
+          />
+          <van-field
+            :model-value="formatYmd(sch.dueDate)"
+            is-link
+            readonly
+            label="应缴费时间"
+            placeholder="选择日期"
+            :disabled="disabled || isScheduleLocked(sch)"
+            @click="!disabled && !isScheduleLocked(sch) && openScheduleDate(pIdx, sIdx)"
+          />
+        </van-cell-group>
+      </div>
+    </div>
+
+    <van-action-sheet
+      v-model:show="modeSheetVisible"
+      :actions="modeSheetActions"
+      cancel-text="取消"
+      close-on-click-action
+      @select="onPayModeSelect"
+    />
+    <van-popup v-model:show="datePickerVisible" position="bottom" round>
+      <van-date-picker
+        v-model="datePickerValue"
+        title="选择日期"
+        :min-date="minDate"
+        :max-date="maxDate"
+        @confirm="onDateConfirm"
+        @cancel="datePickerVisible = false"
+      />
+    </van-popup>
+  </section>
+</template>
+
+<script setup>
+import { computed, ref, watch } from 'vue'
+import { showConfirmDialog, showToast } from 'vant'
+import {
+  applyConsolidateRemainingToPayment,
+  applyExpandRemainingToInstallment,
+  applyLumpSumDeadlineToLastDueDate,
+  availableSamplePayModes,
+  calcLastPeriodDueAmount,
+  DEFAULT_INSTALLMENT_ADVANCE_DAYS,
+  rebuildSampleSchedules,
+  resolvePerSamplePaymentDefaults,
+  samplePaymentHasConfirmedTransfer,
+  shouldAutoFillLastPeriodDue
+} from '../protocol-sample-payment.js'
+
+const props = defineProps({
+  modelValue: { type: Array, default: () => [] },
+  samples: { type: Array, default: () => [] },
+  disabled: { type: Boolean, default: false }
+})
+const emit = defineEmits(['update:modelValue'])
+
+const lastPeriodDueOverrideByKey = ref({})
+const modeSheetVisible = ref(false)
+const modeSheetPayIdx = ref(-1)
+const modeSheetActions = ref([])
+const datePickerVisible = ref(false)
+const datePickerValue = ref(['2026', '10', '13'])
+const dateTarget = ref(null)
+const minDate = new Date(2020, 0, 1)
+const maxDate = new Date(2040, 11, 31)
+
+const syncedPayments = computed(() => props.modelValue || [])
+
+const PAY_MODE_LABELS = { dj: '趸交', fq: '分期', fgd: '分改趸' }
+function payModeLabel(mode) {
+  return PAY_MODE_LABELS[String(mode || '')] || '请选择'
+}
+
+function sampleTitle(pay) {
+  const sample = (props.samples || []).find(
+    (s) => String(s.sampleCode || '') === String(pay.sampleCode || '')
+  )
+  return String(sample?.sampleTypeName || sample?.sampleName || '样本')
+}
+
+function formatYmd(raw) {
+  const compact = String(raw || '').replace(/-/g, '')
+  if (!/^\d{8}$/.test(compact)) return ''
+  return `${compact.slice(0, 4)}-${compact.slice(4, 6)}-${compact.slice(6, 8)}`
+}
+
+function toYmdParts(raw) {
+  const compact = String(raw || '').replace(/-/g, '')
+  if (/^\d{8}$/.test(compact)) {
+    return [compact.slice(0, 4), compact.slice(4, 6), compact.slice(6, 8)]
+  }
+  const now = new Date()
+  return [
+    String(now.getFullYear()),
+    String(now.getMonth() + 1).padStart(2, '0'),
+    String(now.getDate()).padStart(2, '0')
+  ]
+}
+
+function emitList(list) {
+  emit('update:modelValue', list)
+}
+
+function samplePayKey(pay, pIdx) {
+  return String(pay.sampleCode || pay.protocolSampleId || pIdx)
+}
+
+function isScheduleLocked(sch) {
+  return (sch.transfers || []).some((t) => t.payStatus === 'yjf')
+}
+
+function activeScheduleIndices(schedules) {
+  const idx = []
+  ;(schedules || []).forEach((s, i) => {
+    if (String(s.scheduleStatus || 'open') !== 'void' && !s._deleted) idx.push(i)
+  })
+  return idx
+}
+
+function emptyDjPayment(sample) {
+  const defaults = resolvePerSamplePaymentDefaults(sample, 'dj')
+  const amount = defaults.contractAmount
+  return rebuildSampleSchedules({
+    sampleCode: String(sample.sampleCode || ''),
+    protocolSampleId: sample.id || '',
+    payMode: 'dj',
+    contractAmount: amount,
+    lumpSumDeadline: '',
+    installmentCount: null,
+    installmentIntervalMonths: null,
+    installmentStartDate: '',
+    installmentAdvanceDays: DEFAULT_INSTALLMENT_ADVANCE_DAYS,
+    installmentRatios: [],
+    schedules: []
+  })
+}
+
+watch(
+  () => props.samples,
+  (samples) => {
+    const list = [...(props.modelValue || [])]
+    const byCode = new Map(list.map((p) => [String(p.sampleCode || ''), p]))
+    const next = []
+    for (const sample of samples || []) {
+      const code = String(sample.sampleCode || '').trim()
+      if (!code) continue
+      if (byCode.has(code)) {
+        const prev = { ...byCode.get(code), protocolSampleId: sample.id || byCode.get(code).protocolSampleId }
+        next.push(prev)
+      } else {
+        next.push(emptyDjPayment(sample))
+      }
+    }
+    const same =
+      next.length === list.length &&
+      next.every((p, i) => p.sampleCode === list[i]?.sampleCode)
+    if (!same) emitList(next)
+  },
+  { deep: true, immediate: true }
+)
+
+function patchPay(pIdx, patch, rebuild = false) {
+  const list = [...(props.modelValue || [])]
+  let next = { ...list[pIdx], ...patch }
+  if (rebuild) next = rebuildSampleSchedules(next)
+  list[pIdx] = next
+  emitList(list)
+}
+
+function rebuildAt(pIdx) {
+  patchPay(pIdx, {}, true)
+}
+
+function onContractChange(pIdx, raw) {
+  const n = raw === '' || raw == null ? null : Number(raw)
+  patchPay(pIdx, { contractAmount: Number.isFinite(n) ? n : null }, true)
+}
+
+function onFqField(pIdx, key, raw) {
+  const n = raw === '' || raw == null ? null : Number(raw)
+  patchPay(pIdx, { [key]: Number.isFinite(n) ? n : null }, false)
+}
+
+function onDueAmountChange(pIdx, sIdx, raw) {
+  const list = [...(props.modelValue || [])]
+  const pay = { ...list[pIdx] }
+  const schedules = [...(pay.schedules || [])]
+  const n = raw === '' || raw == null ? null : Number(raw)
+  schedules[sIdx] = {
+    ...schedules[sIdx],
+    dueAmount: Number.isFinite(n) ? n : null
+  }
+  const mode = String(pay.payMode || '')
+  const key = samplePayKey(pay, pIdx)
+  const activeIdx = activeScheduleIndices(schedules)
+
+  if (mode === 'fq' && activeIdx.length >= 2) {
+    const lastIdx = activeIdx[activeIdx.length - 1]
+    if (sIdx === lastIdx) {
+      lastPeriodDueOverrideByKey.value = { ...lastPeriodDueOverrideByKey.value, [key]: true }
+    } else if (activeIdx.slice(0, -1).includes(sIdx)) {
+      lastPeriodDueOverrideByKey.value = { ...lastPeriodDueOverrideByKey.value, [key]: false }
+      const lastSch = schedules[lastIdx]
+      const canFill = shouldAutoFillLastPeriodDue({
+        lastManualOverride: false,
+        lastLocked: isScheduleLocked(lastSch)
+      })
+      if (canFill) {
+        const priors = activeIdx.slice(0, -1).map((i) => schedules[i]?.dueAmount)
+        const lastAmt = calcLastPeriodDueAmount(pay.contractAmount, priors)
+        if (lastAmt != null) {
+          schedules[lastIdx] = { ...schedules[lastIdx], dueAmount: lastAmt }
+        }
+      }
+    }
+  }
+
+  pay.schedules = schedules
+  pay.installmentRatios = schedules.map((s) => {
+    const amt = Number(s.dueAmount)
+    return Number.isFinite(amt) && amt > 0 ? amt : 0
+  })
+  list[pIdx] = pay
+  emitList(list)
+}
+
+function openPayModeSheet(pIdx) {
+  const pay = props.modelValue?.[pIdx]
+  if (!pay) return
+  modeSheetPayIdx.value = pIdx
+  modeSheetActions.value = availableSamplePayModes(pay).map((value) => ({
+    name: PAY_MODE_LABELS[value] || value,
+    value
+  }))
+  modeSheetVisible.value = true
+}
+
+async function onPayModeSelect(action) {
+  const pIdx = modeSheetPayIdx.value
+  const mode = action.value
+  const list = props.modelValue || []
+  const prev = list[pIdx]
+  const prevMode = String(prev?.payMode || '')
+  if (!prev || mode === prevMode) return
+
+  const hasPaid = samplePaymentHasConfirmedTransfer(prev)
+  const schCount = (prev.schedules || []).filter(
+    (s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted
+  ).length
+
+  try {
+    if (prevMode === 'fq' && (mode === 'dj' || mode === 'fgd') && schCount > 0) {
+      const label = mode === 'dj' ? '趸交' : '分改趸'
+      await showConfirmDialog({
+        title: `确认改为${label}`,
+        message: hasPaid
+          ? `改为${label}将保留已缴期,并把后续未缴合并至当前未清期。是否继续?`
+          : `改为${label}将把各期未缴合并为当前未清期。是否继续?`
+      })
+      const nextList = [...list]
+      nextList[pIdx] = applyConsolidateRemainingToPayment({ ...nextList[pIdx] }, mode)
+      emitList(nextList)
+      return
+    }
+
+    if (hasPaid && (prevMode === 'dj' || prevMode === 'fgd') && mode === 'fq') {
+      await showConfirmDialog({
+        title: '确认改为分期',
+        message: '改为分期将保留已缴期,剩余金额按分期期数自动生成后续应缴批次。是否继续?'
+      })
+      const cur = { ...list[pIdx] }
+      const count = Number(cur.installmentCount) || 6
+      const interval = Number(cur.installmentIntervalMonths) || 1
+      let startDate = String(cur.installmentStartDate || '').trim()
+      if (!startDate) startDate = String(cur.lumpSumDeadline || '').trim()
+      if (!startDate) {
+        const d = new Date()
+        startDate = `${d.getFullYear()}${String(d.getMonth() + 1).padStart(2, '0')}${String(d.getDate()).padStart(2, '0')}`
+      }
+      const nextList = [...list]
+      nextList[pIdx] = applyExpandRemainingToInstallment(cur, {
+        installmentCount: count,
+        installmentIntervalMonths: interval,
+        installmentStartDate: startDate,
+        installmentAdvanceDays: cur.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS
+      })
+      emitList(nextList)
+      return
+    }
+
+    const sample = (props.samples || []).find(
+      (s) => String(s.sampleCode || '') === String(prev?.sampleCode || '')
+    )
+    const presetMode = mode === 'fq' || mode === 'fgd' ? 'fq' : 'dj'
+    const defaults = resolvePerSamplePaymentDefaults(sample, presetMode)
+    const patch = {
+      payMode: mode,
+      contractAmount: defaults.contractAmount ?? prev?.contractAmount ?? null
+    }
+    if (mode === 'fq' || mode === 'fgd') {
+      patch.installmentCount = mode === 'fgd' ? 3 : defaults.installmentCount || 6
+      patch.installmentIntervalMonths = 1
+      patch.installmentAdvanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
+      patch.installmentRatios = []
+    } else {
+      patch.installmentCount = null
+      patch.installmentRatios = []
+    }
+    patchPay(pIdx, patch, true)
+  } catch (error) {
+    if (error === 'cancel' || error?.message === 'cancel') return
+    showToast(error?.message || '无法切换缴费模式')
+  }
+}
+
+function openDatePicker(pIdx, field) {
+  dateTarget.value = { type: 'pay', pIdx, field }
+  datePickerValue.value = toYmdParts(props.modelValue?.[pIdx]?.[field])
+  datePickerVisible.value = true
+}
+
+function openScheduleDate(pIdx, sIdx) {
+  dateTarget.value = { type: 'schedule', pIdx, sIdx }
+  datePickerValue.value = toYmdParts(props.modelValue?.[pIdx]?.schedules?.[sIdx]?.dueDate)
+  datePickerVisible.value = true
+}
+
+function onDateConfirm({ selectedValues }) {
+  const parts = selectedValues || datePickerValue.value
+  const ymd = `${parts[0]}${parts[1]}${parts[2]}`
+  const target = dateTarget.value
+  datePickerVisible.value = false
+  if (!target) return
+  if (target.type === 'pay') {
+    if (target.field === 'lumpSumDeadline') {
+      const list = [...(props.modelValue || [])]
+      const pay = { ...list[target.pIdx] }
+      pay.lumpSumDeadline = ymd
+      if (String(pay.payMode || '') === 'dj') {
+        pay.schedules = applyLumpSumDeadlineToLastDueDate(
+          [...(pay.schedules || [])],
+          ymd,
+          isScheduleLocked
+        )
+      }
+      list[target.pIdx] = pay
+      emitList(list)
+      return
+    }
+    patchPay(target.pIdx, { [target.field]: ymd }, target.field === 'installmentStartDate')
+    return
+  }
+  if (target.type === 'schedule') {
+    const list = [...(props.modelValue || [])]
+    const pay = { ...list[target.pIdx] }
+    const schedules = [...(pay.schedules || [])]
+    schedules[target.sIdx] = { ...schedules[target.sIdx], dueDate: ymd }
+    pay.schedules = schedules
+    list[target.pIdx] = pay
+    emitList(list)
+  }
+}
+</script>
+
+<style scoped>
+.protocol-sample-pay__hint {
+  margin: 0 16px 12px;
+  color: #64748b;
+  font-size: 12px;
+}
+.protocol-sample-pay__card {
+  margin-bottom: 16px;
+}
+.protocol-sample-pay__head {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  padding: 0 16px 8px;
+}
+.protocol-sample-pay__code {
+  color: #64748b;
+  font-size: 12px;
+}
+.protocol-sample-pay__sub {
+  padding: 12px 16px 8px;
+  font-size: 13px;
+  color: #334155;
+  font-weight: 600;
+}
+.protocol-sample-pay__sch {
+  margin-bottom: 8px;
+}
+.protocol-sample-pay__sch-title {
+  padding: 10px 16px 0;
+  font-size: 13px;
+  color: #0a9f6d;
+  font-weight: 600;
+}
+</style>