فهرست منبع

feat(mobile-oa-h5): port protocol sample payment helpers

Share web-aligned installment autofill and mode-switch helpers for
mobile protocol forms.

Co-authored-by: Cursor <cursoragent@cursor.com>
zlwu 1 ماه پیش
والد
کامیت
66e1c8334c

+ 74 - 0
mobile-oa-h5/src/market/protocol-payment-installment.js

@@ -0,0 +1,74 @@
+/** 分期日期/均摊金额(与 web-admin protocol-payment-installment 对齐) */
+
+const DAYS_PER_MONTH = 30
+export const DEFAULT_INSTALLMENT_ADVANCE_DAYS = 30
+
+export function addMonthsYmd(dateStr, months) {
+  const compact = String(dateStr || '').replace(/-/g, '')
+  if (compact.length !== 8) return ''
+  const y = parseInt(compact.slice(0, 4), 10)
+  const m = parseInt(compact.slice(4, 6), 10) - 1
+  const d = parseInt(compact.slice(6, 8), 10)
+  const date = new Date(y, m + months, d)
+  const yy = date.getFullYear()
+  const mm = String(date.getMonth() + 1).padStart(2, '0')
+  const dd = String(date.getDate()).padStart(2, '0')
+  return `${yy}${mm}${dd}`
+}
+
+export function addDaysYmd(dateStr, days) {
+  const compact = String(dateStr || '').replace(/-/g, '')
+  if (compact.length !== 8) return ''
+  const y = parseInt(compact.slice(0, 4), 10)
+  const m = parseInt(compact.slice(4, 6), 10) - 1
+  const d = parseInt(compact.slice(6, 8), 10)
+  const date = new Date(y, m, d)
+  date.setDate(date.getDate() + days)
+  const yy = date.getFullYear()
+  const mm = String(date.getMonth() + 1).padStart(2, '0')
+  const dd = String(date.getDate()).padStart(2, '0')
+  return `${yy}${mm}${dd}`
+}
+
+export function addIntervalMonthsYmd(dateStr, totalMonths) {
+  const compact = String(dateStr || '').replace(/-/g, '')
+  if (compact.length !== 8 || !Number.isFinite(totalMonths) || totalMonths === 0) {
+    return compact.length === 8 ? compact : ''
+  }
+  const wholeMonths = Math.trunc(totalMonths)
+  const fracMonths = totalMonths - wholeMonths
+  let result = compact
+  if (wholeMonths !== 0) result = addMonthsYmd(result, wholeMonths)
+  if (fracMonths !== 0) {
+    const days = Math.round(fracMonths * DAYS_PER_MONTH)
+    if (days !== 0) result = addDaysYmd(result, days)
+  }
+  return result
+}
+
+export function calcFqInstallmentDueDate(
+  startDate,
+  index,
+  intervalMonths,
+  advanceDays = DEFAULT_INSTALLMENT_ADVANCE_DAYS
+) {
+  const base = addIntervalMonthsYmd(startDate, index * intervalMonths)
+  if (index >= 1) return addDaysYmd(base, -Number(advanceDays || 0))
+  return base
+}
+
+export function defaultInstallmentAmounts(contractAmount, count) {
+  if (contractAmount == null || !count || count < 1) return []
+  const totalCents = Math.round(Number(contractAmount) * 100)
+  const baseCents = Math.floor(totalCents / count)
+  const amounts = []
+  let sumCents = 0
+  for (let i = 0; i < count; i++) {
+    if (i === count - 1) amounts.push((totalCents - sumCents) / 100)
+    else {
+      amounts.push(baseCents / 100)
+      sumCents += baseCents
+    }
+  }
+  return amounts
+}

+ 369 - 0
mobile-oa-h5/src/market/protocol-sample-payment.js

@@ -0,0 +1,369 @@
+/** 按样本缴费 · 工具函数(对齐 web-admin protocol-sample-payment.ts) */
+
+import {
+  calcFqInstallmentDueDate,
+  defaultInstallmentAmounts,
+  DEFAULT_INSTALLMENT_ADVANCE_DAYS
+} from './protocol-payment-installment.js'
+
+export { calcFqInstallmentDueDate, DEFAULT_INSTALLMENT_ADVANCE_DAYS }
+
+export function sumSampleContractAmounts(payments) {
+  return (payments || []).reduce((s, p) => s + (Number(p.contractAmount) || 0), 0)
+}
+
+export function parsePreferentialPrice(sample) {
+  if (sample == null) return null
+  const raw = sample.price
+  if (raw == null || raw === '') return null
+  const n = Number(raw)
+  if (!Number.isFinite(n) || n < 0) return null
+  return Math.round(n * 100) / 100
+}
+
+export function protocolTotalMismatchHint(total, sampleSum) {
+  if (total == null || Number.isNaN(Number(total))) return null
+  if (Math.abs(Number(total) - sampleSum) > 0.015) {
+    return `协议总金额 ${Number(total).toFixed(2)} 与样本合同合计 ${sampleSum.toFixed(2)} 不一致,请核对`
+  }
+  return null
+}
+
+function activeSchedules(pay) {
+  return (pay.schedules || []).filter((s) => String(s.scheduleStatus || 'open') !== 'void')
+}
+
+export function sampleScheduleRequiredFieldsHint(pay) {
+  const mode = String(pay.payMode || '')
+  if (mode !== 'fq' && mode !== 'fgd' && mode !== 'djfq') return null
+  const schedules = activeSchedules(pay)
+  if (!schedules.length) return null
+  const code = String(pay.sampleCode || '').trim()
+  const prefix = code ? `样本 ${code} ` : ''
+  for (let i = 0; i < schedules.length; i++) {
+    const s = schedules[i]
+    const amt = Number(s.dueAmount)
+    if (!Number.isFinite(amt) || amt <= 0) {
+      return `${prefix}第 ${i + 1} 期应缴金额必须填写`
+    }
+    if (!String(s.dueDate || '').trim()) {
+      return `${prefix}第 ${i + 1} 期应缴时间必须填写`
+    }
+  }
+  return null
+}
+
+export function sampleScheduleDuesMismatchHint(pay) {
+  const contract = Number(pay.contractAmount)
+  if (!Number.isFinite(contract)) return null
+  const schedules = activeSchedules(pay)
+  if (!schedules.length) return null
+  const sum = schedules.reduce((acc, x) => acc + (Number(x.dueAmount) || 0), 0)
+  if (Math.abs(contract - sum) > 0.015) {
+    const code = String(pay.sampleCode || '').trim()
+    const prefix = code ? `样本 ${code} ` : ''
+    return `${prefix}合同金额 ${contract.toFixed(2)} 与各期应缴合计 ${sum.toFixed(2)} 不一致,请调整后再保存`
+  }
+  return null
+}
+
+function sumTransferAmountByStatuses(transfers, statuses) {
+  return (transfers || [])
+    .filter((t) => statuses.has(String(t.payStatus || '')))
+    .reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
+}
+
+export function scheduleFinanceConfirmedPaidArrears(dueAmount, transfers) {
+  const paid =
+    Math.round(sumTransferAmountByStatuses(transfers, new Set(['yjf'])) * 100) / 100
+  return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 }
+}
+
+export function schedulePaidArrears(dueAmount, transfers) {
+  const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(dueAmount, transfers)
+  return { paid, arrears: arrears > 0 ? arrears : 0 }
+}
+
+export function resolveSampleContractAmount(sample) {
+  return parsePreferentialPrice(sample)
+}
+
+export function resolvePerSamplePaymentDefaults(sample, payMode) {
+  const contractAmount = resolveSampleContractAmount(sample)
+  if (payMode === 'fq') {
+    return { contractAmount, installmentCount: 6, installmentAmounts: [] }
+  }
+  return { contractAmount }
+}
+
+export function shouldReplaceContractWithPreferentialPrice(contractAmount, preferentialPrice) {
+  if (preferentialPrice == null) return false
+  if (contractAmount == null || !Number.isFinite(Number(contractAmount))) return true
+  return false
+}
+
+export function parseInstallmentRatios(raw) {
+  if (Array.isArray(raw)) return raw.map((v) => Number(v) || 0)
+  if (typeof raw === 'string' && raw.trim()) {
+    try {
+      const parsed = JSON.parse(raw)
+      if (Array.isArray(parsed)) return parsed.map((v) => Number(v) || 0)
+    } catch {
+      /* ignore */
+    }
+  }
+  return []
+}
+
+function roundMoney(n) {
+  return Math.round(n * 100) / 100
+}
+
+export function planFqToFgdMerge(schedules) {
+  const schList = (schedules || [])
+    .filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted)
+    .slice()
+    .sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
+
+  const arrearsOf = (s) => {
+    const { arrears } = scheduleFinanceConfirmedPaidArrears(
+      Number(s.dueAmount) || 0,
+      s.transfers || []
+    )
+    return arrears > 0 ? arrears : 0
+  }
+
+  let target
+  for (const s of schList) {
+    if (arrearsOf(s) > 0) {
+      target = s
+      break
+    }
+  }
+  if (!target) {
+    target = schList.find((s) => String(s.scheduleStatus || 'open') !== 'cleared')
+  }
+  if (!target) throw new Error('没有可合并的未清期次')
+
+  const targetNo = Number(target.scheduleNo || 0)
+  const later = schList.filter((s) => Number(s.scheduleNo || 0) > targetNo)
+  for (const s of later) {
+    if ((s.transfers || []).some((t) => t.payStatus === 'yjf')) {
+      throw new Error('后续期已有已确认转账,无法合并为分改趸')
+    }
+  }
+
+  const remain = roundMoney(schList.reduce((sum, s) => sum + arrearsOf(s), 0))
+  const { paid: targetConfirmed } = scheduleFinanceConfirmedPaidArrears(
+    Number(target.dueAmount) || 0,
+    target.transfers || []
+  )
+  const newDueAmount = roundMoney(targetConfirmed + remain)
+  const softDeleteScheduleIds = later.map((s) => String(s.id || '').trim()).filter(Boolean)
+  const softDeleteScheduleNos = later.map((s) => Number(s.scheduleNo || 0))
+  const softNoSet = new Set(softDeleteScheduleNos)
+  const kept = schList.filter((s) => !softNoSet.has(Number(s.scheduleNo || 0)))
+
+  return {
+    targetScheduleId: String(target.id || ''),
+    targetScheduleNo: targetNo,
+    newDueAmount,
+    softDeleteScheduleIds,
+    softDeleteScheduleNos,
+    newInstallmentCount: kept.length
+  }
+}
+
+export function samplePaymentHasConfirmedTransfer(pay) {
+  return (pay.schedules || []).some((s) =>
+    (s.transfers || []).some((t) => t.payStatus === 'yjf')
+  )
+}
+
+export function availableSamplePayModes(pay) {
+  const mode = String(pay.payMode || 'dj')
+  if (mode === 'fq') return ['fq', 'dj', 'fgd']
+  if (mode === 'fgd') return ['fgd', 'fq']
+  return ['dj', 'fq']
+}
+
+export function applyConsolidateRemainingToPayment(pay, targetMode) {
+  const plan = planFqToFgdMerge(pay.schedules || [])
+  const softNoSet = new Set(plan.softDeleteScheduleNos)
+  const schedules = (pay.schedules || [])
+    .filter(
+      (s) =>
+        String(s.scheduleStatus || 'open') !== 'void' &&
+        !s._deleted &&
+        !softNoSet.has(Number(s.scheduleNo || 0))
+    )
+    .map((s) => {
+      if (Number(s.scheduleNo || 0) !== plan.targetScheduleNo) return { ...s }
+      return { ...s, dueAmount: plan.newDueAmount }
+    })
+    .sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
+
+  return {
+    ...pay,
+    payMode: targetMode,
+    installmentCount: plan.newInstallmentCount,
+    schedules,
+    installmentRatios: schedules.map((s) => {
+      const n = Number(s.dueAmount)
+      return Number.isFinite(n) && n > 0 ? n : 0
+    })
+  }
+}
+
+export function applyFqToFgdMergeToPayment(pay) {
+  return applyConsolidateRemainingToPayment(pay, 'fgd')
+}
+
+export function applyExpandRemainingToInstallment(pay, opts) {
+  const schList = (pay.schedules || [])
+    .filter((s) => String(s.scheduleStatus || 'open') !== 'void' && !s._deleted)
+    .slice()
+    .sort((a, b) => Number(a.scheduleNo || 0) - Number(b.scheduleNo || 0))
+
+  for (const s of schList) {
+    if ((s.transfers || []).some((t) => t.payStatus === 'jfqrz')) {
+      throw new Error('存在付款确认中的转账,请先完成财务确认')
+    }
+  }
+
+  let totalPaid = 0
+  const kept = []
+  for (const s of schList) {
+    const { paid, arrears } = scheduleFinanceConfirmedPaidArrears(
+      Number(s.dueAmount) || 0,
+      s.transfers || []
+    )
+    totalPaid = roundMoney(totalPaid + paid)
+    if (paid > 0) {
+      kept.push({
+        ...s,
+        dueAmount: arrears > 0 ? paid : s.dueAmount,
+        scheduleStatus: arrears > 0 ? 'cleared' : s.scheduleStatus || 'cleared',
+        transfers: [...(s.transfers || [])]
+      })
+    }
+  }
+
+  const contract = Number(pay.contractAmount)
+  if (!Number.isFinite(contract)) throw new Error('请先填写样本合同金额')
+  const remaining = roundMoney(contract - totalPaid)
+  if (remaining <= 0) throw new Error('已缴清,无法改为分期')
+
+  const count = Math.max(1, Number(opts.installmentCount) || 6)
+  const newCount = Math.max(1, count - kept.length)
+  const interval = Number(opts.installmentIntervalMonths) || 1
+  const startDate = String(opts.installmentStartDate || '').trim()
+  if (!startDate) throw new Error('请先填写分期开始时间')
+  const advanceDays = Number(opts.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS)
+  const amounts = defaultInstallmentAmounts(remaining, newCount)
+  const maxNo = kept.reduce((m, s) => Math.max(m, Number(s.scheduleNo) || 0), 0)
+  const created = amounts.map((amt, i) => ({
+    scheduleNo: maxNo + 1 + i,
+    dueAmount: amt,
+    dueDate: calcFqInstallmentDueDate(startDate, i, interval, advanceDays),
+    scheduleStatus: 'open',
+    transfers: []
+  }))
+
+  const schedules = [...kept, ...created]
+  return {
+    ...pay,
+    payMode: 'fq',
+    installmentCount: schedules.length,
+    installmentIntervalMonths: interval,
+    installmentStartDate: startDate,
+    installmentAdvanceDays: advanceDays,
+    schedules,
+    installmentRatios: schedules.map((s) => {
+      const n = Number(s.dueAmount)
+      return Number.isFinite(n) && n > 0 ? n : 0
+    })
+  }
+}
+
+export function calcLastPeriodDueAmount(contractAmount, priorAmounts) {
+  if (contractAmount == null) return null
+  const contract = Number(contractAmount)
+  if (!Number.isFinite(contract)) return null
+  if (!priorAmounts.length) return null
+  let anyFilled = false
+  let sumCents = 0
+  for (const raw of priorAmounts) {
+    if (raw === null || raw === undefined || raw === '') continue
+    const n = Number(raw)
+    if (!Number.isFinite(n)) continue
+    anyFilled = true
+    sumCents += Math.round(n * 100)
+  }
+  if (!anyFilled) return null
+  const last = Math.round(contract * 100 - sumCents) / 100
+  return last < 0 ? 0 : last
+}
+
+export function shouldAutoFillLastPeriodDue(opts) {
+  return !opts.lastManualOverride && !opts.lastLocked
+}
+
+export function applyLumpSumDeadlineToLastDueDate(schedules, deadline, isLocked) {
+  const due = String(deadline || '').trim()
+  const activeIdx = []
+  schedules.forEach((s, i) => {
+    if (String(s.scheduleStatus || 'open') !== 'void') activeIdx.push(i)
+  })
+  if (!activeIdx.length) return schedules.map((s) => ({ ...s }))
+  const lastIdx = activeIdx[activeIdx.length - 1]
+  if (isLocked(schedules[lastIdx])) return schedules.map((s) => ({ ...s }))
+  return schedules.map((s, i) => (i === lastIdx ? { ...s, dueDate: due } : { ...s }))
+}
+
+/** 按缴费参数重建应缴批次(对齐 SamplePaymentPanel.rebuildSchedules 精简版) */
+export function rebuildSampleSchedules(pay) {
+  const mode = pay.payMode || 'dj'
+  if (mode === 'dj') {
+    const prev = (pay.schedules || []).find((s) => Number(s.scheduleNo || 0) === 1)
+    return {
+      ...pay,
+      schedules: [
+        {
+          id: prev?.id,
+          scheduleNo: 1,
+          dueAmount: pay.contractAmount,
+          dueDate: pay.lumpSumDeadline || '',
+          scheduleStatus: prev?.scheduleStatus || 'open',
+          transfers: [...(prev?.transfers || [])]
+        }
+      ]
+    }
+  }
+  const count = Number(pay.installmentCount) || 0
+  const interval = Number(pay.installmentIntervalMonths) || 0
+  const startDate = pay.installmentStartDate || ''
+  const advanceDays = Number(pay.installmentAdvanceDays ?? DEFAULT_INSTALLMENT_ADVANCE_DAYS)
+  if (!count || !interval || !startDate) {
+    return { ...pay, schedules: [] }
+  }
+  const prevByNo = new Map(
+    (pay.schedules || []).map((s) => [Number(s.scheduleNo || 0), s])
+  )
+  return {
+    ...pay,
+    schedules: Array.from({ length: count }, (_, i) => {
+      const scheduleNo = i + 1
+      const prev = prevByNo.get(scheduleNo)
+      const prevAmt = prev?.dueAmount
+      return {
+        id: prev?.id,
+        scheduleNo,
+        dueAmount: prevAmt != null && Number(prevAmt) > 0 ? Number(prevAmt) : null,
+        dueDate: prev?.dueDate || calcFqInstallmentDueDate(startDate, i, interval, advanceDays) || '',
+        scheduleStatus: prev?.scheduleStatus || 'open',
+        transfers: [...(prev?.transfers || [])]
+      }
+    })
+  }
+}

+ 101 - 0
mobile-oa-h5/src/market/protocol-sample-payment.test.js

@@ -0,0 +1,101 @@
+import { describe, it } from 'node:test'
+import assert from 'node:assert/strict'
+import {
+  calcLastPeriodDueAmount,
+  shouldAutoFillLastPeriodDue,
+  applyLumpSumDeadlineToLastDueDate,
+  availableSamplePayModes,
+  applyConsolidateRemainingToPayment,
+  sampleScheduleRequiredFieldsHint,
+  sampleScheduleDuesMismatchHint
+} from './protocol-sample-payment.js'
+
+describe('calcLastPeriodDueAmount', () => {
+  it('last = contract − priors', () => {
+    assert.equal(calcLastPeriodDueAmount(20000, [10000, 5000]), 5000)
+    assert.equal(calcLastPeriodDueAmount(100, [33.33, 33.33]), 33.34)
+  })
+  it('returns null when contract invalid or priors empty', () => {
+    assert.equal(calcLastPeriodDueAmount(null, [1000]), null)
+    assert.equal(calcLastPeriodDueAmount(20000, [null, '']), null)
+  })
+})
+
+describe('shouldAutoFillLastPeriodDue', () => {
+  it('blocks override/locked', () => {
+    assert.equal(
+      shouldAutoFillLastPeriodDue({ lastManualOverride: true, lastLocked: false }),
+      false
+    )
+    assert.equal(
+      shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: true }),
+      false
+    )
+  })
+  it('allows otherwise', () => {
+    assert.equal(
+      shouldAutoFillLastPeriodDue({ lastManualOverride: false, lastLocked: false }),
+      true
+    )
+  })
+})
+
+describe('applyLumpSumDeadlineToLastDueDate', () => {
+  it('writes last dueDate', () => {
+    const next = applyLumpSumDeadlineToLastDueDate(
+      [{ scheduleNo: 1, dueDate: '', scheduleStatus: 'open' }],
+      '20261231',
+      () => false
+    )
+    assert.equal(next[0].dueDate, '20261231')
+  })
+})
+
+describe('availableSamplePayModes', () => {
+  it('fq can switch to dj/fgd', () => {
+    assert.deepEqual(availableSamplePayModes({ payMode: 'fq' }), ['fq', 'dj', 'fgd'])
+  })
+})
+
+describe('applyConsolidateRemainingToPayment', () => {
+  it('fq→fgd consolidates unpaid', () => {
+    const next = applyConsolidateRemainingToPayment(
+      {
+        payMode: 'fq',
+        installmentCount: 3,
+        contractAmount: 30000,
+        schedules: [
+          { scheduleNo: 1, dueAmount: 10000, transfers: [] },
+          { scheduleNo: 2, dueAmount: 10000, transfers: [] },
+          { scheduleNo: 3, dueAmount: 10000, transfers: [] }
+        ]
+      },
+      'fgd'
+    )
+    assert.equal(next.payMode, 'fgd')
+    assert.equal(next.schedules.length, 1)
+    assert.equal(next.schedules[0].dueAmount, 30000)
+  })
+})
+
+describe('hints', () => {
+  it('requires fq amounts', () => {
+    const hint = sampleScheduleRequiredFieldsHint({
+      payMode: 'fq',
+      sampleCode: 'S1',
+      schedules: [{ dueAmount: null, dueDate: '20261001' }]
+    })
+    assert.match(hint, /应缴金额/)
+  })
+  it('dues must match contract', () => {
+    const hint = sampleScheduleDuesMismatchHint({
+      payMode: 'fq',
+      contractAmount: 20000,
+      schedules: [
+        { dueAmount: 10000, dueDate: '20261001' },
+        { dueAmount: 5000, dueDate: '20261101' }
+      ]
+    })
+    assert.match(hint, /不一致/)
+  })
+})