feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强

汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-10-10 21:20:37 +08:00
1 parent 2d3472f5c4
commit db31990ebe
235 files changed
+14898 -10196

No files matched your search

+353 -12
View File
@@ -1,4 +1,12 @@
/** 按样本缴费 · 工具函数(payment_model_version=2) */
/** 按样本缴费 · 工具函数(payment_model_version=2)
* 合同金额唯一来源:样本优惠价 price;不再使用硬编码价目表。
*/
import {
calcFqInstallmentDueDate,
defaultInstallmentAmounts,
DEFAULT_INSTALLMENT_ADVANCE_DAYS
} from '@/utils/protocol-payment-installment'
export type SamplePaymentLike = {
contractAmount?: number | null
@@ -7,6 +15,10 @@ export type SamplePaymentLike = {
schedules?: ScheduleLike[]
}
export type SamplePriceLike = {
price?: number | string | null
}
export type TransferLike = {
payStatus?: string
transferAmount?: number | null
@@ -14,6 +26,8 @@ export type TransferLike = {
export type ScheduleLike = {
dueAmount?: number | null
dueDate?: string | null
scheduleStatus?: string
transfers?: TransferLike[]
}
@@ -21,6 +35,18 @@ export function sumSampleContractAmounts(payments: SamplePaymentLike[]): number
return payments.reduce((s, p) => s + (Number(p.contractAmount) || 0), 0)
}
/** 样本优惠价格(元);未填返回 null */
export function parsePreferentialPrice(
sample: SamplePriceLike | null | undefined
): number | null {
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: number | null | undefined,
sampleSum: number
@@ -32,31 +58,118 @@ export function protocolTotalMismatchHint(
return null
}
function activeSchedules(pay: SamplePaymentLike): ScheduleLike[] {
return (pay.schedules || []).filter((s) => String(s.scheduleStatus || 'open') !== 'void')
}
/** 分期/分改趸:每期应缴金额与应缴时间必填 */
export function sampleScheduleRequiredFieldsHint(pay: SamplePaymentLike): string | null {
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: SamplePaymentLike): string | null {
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
}
/** 展示口径已缴:与后端 chase_period_money / 缴费信息期次一致 */
const DISPLAY_PAID_STATUSES = new Set(['dfj', 'jfqrz', 'yjf'])
function sumTransferAmountByStatuses(
transfers: TransferLike[] | undefined,
statuses: Set<string>
): number {
return (transfers || [])
.filter((t) => statuses.has(String(t.payStatus || '')))
.reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
}
/**
* 期次已缴/欠缴(展示):已缴 = dfj + jfqrz + yjf;欠缴 = max(应缴 − 已缴, 0)
*/
export function schedulePaidArrears(
dueAmount: number,
transfers: TransferLike[]
): { paid: number; arrears: number } {
const paid = (transfers || [])
.filter((t) => t.payStatus === 'yjf')
.reduce((s, t) => s + (Number(t.transferAmount) || 0), 0)
const paid = Math.round(sumTransferAmountByStatuses(transfers, DISPLAY_PAID_STATUSES) * 100) / 100
const arrears = Math.round((dueAmount - paid) * 100) / 100
return { paid, arrears: arrears > 0 ? arrears : 0 }
}
/** 财务已确认口径(仅 yjf):用于分改趸并期等与后端 plan_fq_to_fgd_merge 对齐 */
export function scheduleFinanceConfirmedPaidArrears(
dueAmount: number,
transfers: TransferLike[]
): { paid: number; arrears: number } {
const paid =
Math.round(sumTransferAmountByStatuses(transfers, new Set(['yjf'])) * 100) / 100
return { paid, arrears: Math.round((dueAmount - paid) * 100) / 100 }
}
/** 单样本默认定价:沿用原「1 个样本」价目 */
export function resolvePerSamplePreset(payMode: 'dj' | 'fq'): {
contractAmount: number
/** 单样本合同金额 = 优惠价格;未填优惠价则 null */
export function resolveSampleContractAmount(
sample: SamplePriceLike | null | undefined
): number | null {
return parsePreferentialPrice(sample)
}
/** 新建/切换缴费模式:合同取优惠价;分期不预填各期金额 */
export function resolvePerSamplePaymentDefaults(
sample: SamplePriceLike | null | undefined,
payMode: 'dj' | 'fq'
): {
contractAmount: number | null
installmentCount?: number
installmentAmounts?: number[]
} | null {
if (payMode === 'dj') return { contractAmount: 22880 }
} {
const contractAmount = resolveSampleContractAmount(sample)
if (payMode === 'fq') {
return {
contractAmount: 25880,
contractAmount,
installmentCount: 6,
installmentAmounts: [6480, 3880, 3880, 3880, 3880, 3880]
installmentAmounts: []
}
}
return null
return { contractAmount }
}
/**
* 是否用优惠价覆盖合同:仅合同为空时(打开编辑不覆盖已有库内金额)。
* 用户改优惠价时由调用方 force=true 强制同步。
*/
export function shouldReplaceContractWithPreferentialPrice(
contractAmount: number | null | undefined,
preferentialPrice: number | null
): boolean {
if (preferentialPrice == null) return false
if (contractAmount == null || !Number.isFinite(Number(contractAmount))) return true
return false
}
export function parseInstallmentRatios(raw: unknown): number[] {
@@ -71,3 +184,231 @@ export function parseInstallmentRatios(raw: unknown): number[] {
}
return []
}
export type FgdScheduleLike = ScheduleLike & {
id?: string
scheduleNo?: number
_deleted?: boolean
}
export type FgdMergePlan = {
targetScheduleId: string
targetScheduleNo: number
newDueAmount: number
softDeleteScheduleIds: string[]
softDeleteScheduleNos: number[]
newInstallmentCount: number
}
function roundMoney(n: number): number {
return Math.round(n * 100) / 100
}
/** fq → fgd:保留已缴期,剩余欠缴并入第一期未清期;软删其后各期(与后端 plan_fq_to_fgd_merge 对齐) */
export function planFqToFgdMerge(schedules: FgdScheduleLike[]): FgdMergePlan {
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: FgdScheduleLike): number => {
const { arrears } = scheduleFinanceConfirmedPaidArrears(
Number(s.dueAmount) || 0,
s.transfers || []
)
return arrears > 0 ? arrears : 0
}
let target: FgdScheduleLike | undefined
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 type SamplePayMode = 'dj' | 'fq' | 'fgd'
export function samplePaymentHasConfirmedTransfer(pay: {
schedules?: FgdScheduleLike[]
}): boolean {
return (pay.schedules || []).some((s) =>
(s.transfers || []).some((t) => t.payStatus === 'yjf')
)
}
/**
* 下拉可选缴费模式(趸交 ↔ 分改趸 互斥,不可直转):
* - 分期:可改趸交 / 分改趸
* - 趸交:仅可改分期
* - 分改趸:仅可改分期
*/
export function availableSamplePayModes(pay: {
payMode?: string
schedules?: FgdScheduleLike[]
}): SamplePayMode[] {
const mode = String(pay.payMode || 'dj') as SamplePayMode
if (mode === 'fq') return ['fq', 'dj', 'fgd']
if (mode === 'fgd') return ['fgd', 'fq']
return ['dj', 'fq']
}
/** 将剩余欠缴并入当前未清期,软删后续未缴期(fq→dj / fq→fgd) */
export function applyConsolidateRemainingToPayment<T extends Record<string, any>>(
pay: T,
targetMode: 'dj' | 'fgd'
): T {
const plan = planFqToFgdMerge((pay.schedules || []) as FgdScheduleLike[])
const softNoSet = new Set(plan.softDeleteScheduleNos)
const schedules = ((pay.schedules || []) as FgdScheduleLike[])
.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<T extends Record<string, any>>(pay: T): T {
return applyConsolidateRemainingToPayment(pay, 'fgd')
}
export type ExpandInstallmentOpts = {
installmentCount?: number | null
installmentIntervalMonths?: number | null
installmentStartDate?: string
installmentAdvanceDays?: number | null
}
/** dj/fgd → fq:保留已确认实缴期,剩余按分期参数拆成新期 */
export function applyExpandRemainingToInstallment<T extends Record<string, any>>(
pay: T,
opts: ExpandInstallmentOpts
): T {
const schList = ((pay.schedules || []) as FgdScheduleLike[])
.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: FgdScheduleLike[] = []
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: FgdScheduleLike[] = 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
})
}
}