feat: 细胞存储缴费催缴、统一样本信息与协议支付能力增强
汇总近期业务改动:催缴菜单与后端、样本信息快捷登记与进度、运费关联、客户档案扩展、协议优惠/期次与押金划转,并同步 SQL 迁移与管理端页面收敛。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1 parent
2d3472f5c4
commit
db31990ebe
235 files changed
+14898
-10196
No files matched your search
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user