This commit is contained in:
zlwu committed 2026-06-04 12:57:59 +08:00
1 parent afd517ce10
commit eec61cb5dd
82 files changed
+16585 -9026

No files matched your search

@@ -0,0 +1,204 @@
/**
* 分期参数变更(期数 / 间隔 / 开始时间),非缴费模式切换
* 规则见 docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md §分期参数变更
*/
import {
calcFqInstallmentDueDate,
calcInstallmentDueAmounts,
sortFqPaymentDetails
} from '@/utils/protocol-payment-installment'
import {
calcRemainingAmount,
createPaymentDetailRow,
type DetailLike,
type EvaluateResult
} from '@/utils/protocol-pay-mode-switch'
export interface FqParams {
installmentCount: number | null
installmentIntervalMonths: number | null
installmentStartDate: string
}
const BLOCKING_STATUSES = new Set(['jfqrz', 'tfqrz', 'ytf'])
function formatMoneySummary(v: number): string {
return `¥${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}
function countByStatus(details: DetailLike[], status: string): number {
return details.filter((d) => String(d.payStatus || 'dfj') === status).length
}
export function fqParamsSnapshot(params: FqParams): FqParams {
return {
installmentCount: params.installmentCount,
installmentIntervalMonths: params.installmentIntervalMonths,
installmentStartDate: params.installmentStartDate || ''
}
}
export function isSameFqParams(a: FqParams, b: FqParams): boolean {
return (
Number(a.installmentCount) === Number(b.installmentCount) &&
Number(a.installmentIntervalMonths) === Number(b.installmentIntervalMonths) &&
String(a.installmentStartDate || '') === String(b.installmentStartDate || '')
)
}
export function getPaidInstallmentRows(details: DetailLike[]): DetailLike[] {
return [...details]
.filter((d) => String(d.payStatus) === 'yjf')
.sort((a, b) => Number(a.installmentNo || 0) - Number(b.installmentNo || 0))
}
function maxPaidInstallmentNo(details: DetailLike[]): number {
return getPaidInstallmentRows(details).reduce(
(max, d) => Math.max(max, Number(d.installmentNo || 0)),
0
)
}
export function evaluateFqParamsChange(
_oldParams: FqParams,
newParams: FqParams,
details: DetailLike[],
contractAmount: number | null
): EvaluateResult {
const rows = details.filter((d) => !d._voided)
const newCount = Number(newParams.installmentCount)
const interval = Number(newParams.installmentIntervalMonths)
const startDate = String(newParams.installmentStartDate || '')
if (!newCount || newCount < 1) {
return { action: 'blocked', reason: '请填写有效的分期期数。' }
}
if (!interval || interval <= 0) {
return { action: 'blocked', reason: '请填写有效的每期间隔(月)。' }
}
if (!startDate) {
return { action: 'blocked', reason: '请填写分期开始时间。' }
}
if (rows.some((d) => BLOCKING_STATUSES.has(String(d.payStatus || '')))) {
return {
action: 'blocked',
reason: '存在缴费确认中或退费处理中的明细,请先完成相关流程后再修改分期参数。'
}
}
const paidRows = getPaidInstallmentRows(rows)
const maxPaidNo = maxPaidInstallmentNo(rows)
if (maxPaidNo > 0 && newCount < maxPaidNo) {
return {
action: 'blocked',
reason: `已有第 ${maxPaidNo} 期缴费记录,分期期数不能少于 ${maxPaidNo}。`
}
}
const dfjCount = countByStatus(rows, 'dfj')
if (paidRows.length === 0) {
if (rows.length === 0) {
return { action: 'noop' }
}
return {
action: 'confirm',
summary: `将按新参数(${newCount} 期)重新生成缴费明细,原有 ${rows.length} 条待缴计划将被替换。是否继续?`
}
}
const remaining = calcRemainingAmount(contractAmount, rows)
const newUnpaidCount = newCount - paidRows.length
if (newUnpaidCount < 0) {
return {
action: 'blocked',
reason: `已有 ${paidRows.length} 期缴费记录,分期期数不能少于 ${paidRows.length}。`
}
}
if (remaining <= 0 && dfjCount > 0) {
return { action: 'blocked', reason: '已缴清,无需调整分期计划。' }
}
const countChanged = Number(_oldParams.installmentCount) !== newCount
if (countChanged) {
return {
action: 'confirm',
summary: `将保留 ${paidRows.length} 期已缴记录,作废 ${dfjCount} 期未缴计划,并重新生成 ${newUnpaidCount} 期未缴明细(剩余 ${formatMoneySummary(remaining)})。是否继续?`
}
}
return {
action: 'confirm',
summary: `将保留已缴记录,并更新 ${dfjCount} 期未缴计划的应缴时间与金额(剩余 ${formatMoneySummary(remaining)})。是否继续?`
}
}
/** 按新分期参数重建明细:保留 yjf,作废原 dfj,生成剩余期次 */
export function applyFqParamsChange(
params: FqParams,
details: DetailLike[],
contractAmount: number | null
): DetailLike[] {
const count = Number(params.installmentCount) || 0
const interval = Number(params.installmentIntervalMonths) || 0
const startDate = params.installmentStartDate || ''
if (!count || !interval || !startDate) return details
const paidRows = getPaidInstallmentRows(details).map((d) => ({
...d,
_payModeLocked: true
}))
const remainingCount = count - paidRows.length
const remaining = calcRemainingAmount(contractAmount, paidRows)
const amounts =
remainingCount > 0
? calcInstallmentDueAmounts(remaining, remainingCount)
: []
const result: DetailLike[] = [...paidRows]
for (let i = 0; i < remainingCount; i++) {
const periodIndex = paidRows.length + i
result.push(
createPaymentDetailRow({
dueAmount: amounts[i] ?? null,
installmentNo: periodIndex + 1,
installmentDueDate: calcFqInstallmentDueDate(startDate, periodIndex, interval),
expanded: false
})
)
}
return sortFqPaymentDetails(result)
}
/** 首次填充分期参数时生成明细(无需确认) */
export function generateInitialFqDetails(
params: FqParams,
contractAmount: number | null
): DetailLike[] {
const count = Number(params.installmentCount) || 0
const interval = Number(params.installmentIntervalMonths) || 0
const startDate = params.installmentStartDate || ''
if (!count || !interval || !startDate) return []
const amounts = calcInstallmentDueAmounts(contractAmount, count)
const result: DetailLike[] = []
for (let i = 0; i < count; i++) {
result.push(
createPaymentDetailRow({
dueAmount: amounts[i] ?? null,
installmentNo: i + 1,
installmentDueDate: calcFqInstallmentDueDate(startDate, i, interval),
expanded: i === 0
})
)
}
return result
}
@@ -0,0 +1,216 @@
/**
* 采集协议单 · 缴费模式切换(dj ↔ fq)
* 规则见 docs/superpowers/specs/2026-06-04-protocol-pay-mode-switch-design.md
*/
import {
calcFqInstallmentDueDate,
calcInstallmentDueAmounts
} from '@/utils/protocol-payment-installment'
export type PayMode = 'dj' | 'fq'
export type DetailLike = Record<string, any>
const LOCKED_STATUSES = new Set(['jfqrz', 'yjf', 'tfqrz', 'ytf'])
const BLOCKING_STATUSES = new Set(['jfqrz', 'tfqrz', 'ytf'])
export interface PayModeSwitchContext {
contractAmount: number | null
lumpSumDeadline: string
installmentCount: number | null
installmentIntervalMonths: number | null
installmentStartDate: string
}
export type EvaluateResult =
| { action: 'noop' }
| { action: 'blocked'; reason: string }
| { action: 'confirm'; summary: string }
export function isPayDetailLocked(detail: DetailLike): boolean {
if (detail._payModeLocked) return true
return LOCKED_STATUSES.has(String(detail.payStatus || ''))
}
export function markPayDetailLocks(details: DetailLike[]): void {
for (const d of details) {
d._payModeLocked = isPayDetailLocked(d)
}
}
export function sumPaidAmount(details: DetailLike[]): number {
return details
.filter((d) => String(d.payStatus) === 'yjf')
.reduce((sum, d) => sum + (Number(d.dueAmount) || 0), 0)
}
export function calcRemainingAmount(
contractAmount: number | null,
details: DetailLike[]
): number {
if (contractAmount == null) return 0
const remaining = Number(contractAmount) - sumPaidAmount(details)
return Math.round(remaining * 100) / 100
}
function formatMoneySummary(v: number): string {
return `¥${v.toFixed(2).replace(/\B(?=(\d{3})+(?!\d))/g, ',')}`
}
function activeDetails(details: DetailLike[]): DetailLike[] {
return details.filter((d) => !d._voided)
}
function hasBlockingStatus(details: DetailLike[]): boolean {
return details.some((d) => BLOCKING_STATUSES.has(String(d.payStatus || '')))
}
function countByStatus(details: DetailLike[], status: string): number {
return details.filter((d) => String(d.payStatus || 'dfj') === status).length
}
export function createPaymentDetailRow(options: {
dueAmount: number | null
installmentDueDate?: string
installmentNo?: number | null
payStatus?: string
expanded?: boolean
}): DetailLike {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
const defaultTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
return {
transferType: '',
dueAmount: options.dueAmount,
transferAmount: null,
transferTime: defaultTime,
payStatus: options.payStatus ?? 'dfj',
installmentNo: options.installmentNo ?? null,
installmentDueDate: options.installmentDueDate ?? '',
_expanded: options.expanded ?? false,
_payModeLocked: false,
attachments: []
}
}
export function evaluatePayModeSwitch(
fromMode: PayMode,
toMode: PayMode,
details: DetailLike[],
ctx: PayModeSwitchContext
): EvaluateResult {
if (fromMode === toMode) return { action: 'noop' }
const rows = activeDetails(details)
if (hasBlockingStatus(rows)) {
return {
action: 'blocked',
reason: '存在缴费确认中或退费处理中的明细,请先完成相关流程后再切换缴费模式。'
}
}
if (fromMode === 'dj' && toMode === 'fq') {
if (rows.some((d) => isPayDetailLocked(d))) {
return {
action: 'blocked',
reason: '趸交已有缴费记录,无法改为分期。'
}
}
const count = ctx.installmentCount || 6
return {
action: 'confirm',
summary: `将清除当前趸交计划,按 ${count} 期分期重新生成缴费明细。是否继续?`
}
}
if (fromMode === 'fq' && toMode === 'dj') {
const dfjCount = countByStatus(rows, 'dfj')
const yjfCount = countByStatus(rows, 'yjf')
if (yjfCount > 0 && dfjCount === 0) {
return {
action: 'blocked',
reason: '分期已全部缴清,无需改为趸交。'
}
}
const remaining = calcRemainingAmount(ctx.contractAmount, rows)
if (yjfCount > 0 && remaining <= 0) {
return {
action: 'blocked',
reason: '已缴清,无需改为趸交。'
}
}
if (yjfCount > 0) {
return {
action: 'confirm',
summary: `将作废 ${dfjCount} 期未缴计划,保留 ${yjfCount} 期已缴记录,并新增 1 条趸交明细(剩余 ${formatMoneySummary(remaining)})。是否继续?`
}
}
return {
action: 'confirm',
summary: `将作废 ${dfjCount || rows.length} 期分期计划,并合并为 1 条趸交明细。是否继续?`
}
}
return { action: 'noop' }
}
export function applyPayModeSwitch(
fromMode: PayMode,
toMode: PayMode,
details: DetailLike[],
ctx: PayModeSwitchContext
): DetailLike[] {
const rows = activeDetails(details)
if (fromMode === 'dj' && toMode === 'fq') {
const count = Number(ctx.installmentCount) || 6
const interval = Number(ctx.installmentIntervalMonths) || 6
const startDate = ctx.installmentStartDate || ''
const amounts = calcInstallmentDueAmounts(ctx.contractAmount, count)
const result: DetailLike[] = []
for (let i = 0; i < count; i++) {
result.push(
createPaymentDetailRow({
dueAmount: amounts[i] ?? null,
installmentNo: i + 1,
installmentDueDate:
startDate && interval ? calcFqInstallmentDueDate(startDate, i, interval) : '',
expanded: i === 0
})
)
}
return result
}
if (fromMode === 'fq' && toMode === 'dj') {
const yjfRows = rows
.filter((d) => String(d.payStatus) === 'yjf')
.map((d) => ({ ...d, _payModeLocked: true }))
const remaining = calcRemainingAmount(ctx.contractAmount, yjfRows)
const deadline = ctx.lumpSumDeadline || ''
const dueAmount = yjfRows.length ? remaining : ctx.contractAmount
const newDjRow = createPaymentDetailRow({
dueAmount,
installmentNo: null,
installmentDueDate: deadline,
expanded: yjfRows.length === 0
})
return [...yjfRows, newDjRow]
}
return rows
}
/** 趸交模式下是否还可添加待缴明细 */
export function canAddPaymentDetail(payMode: string, details: DetailLike[]): boolean {
if (payMode === 'fq') return true
const dfjCount = details.filter(
(d) => !isPayDetailLocked(d) && String(d.payStatus || 'dfj') === 'dfj'
).length
return dfjCount < 1
}
@@ -1,5 +1,7 @@
/** 分期缴费:期数对齐、应缴日/金额计算(与 ProtocolFormDialog 逻辑一致) */
const DAYS_PER_MONTH = 30
export function addMonthsYmd(dateStr: string, months: number): string {
const compact = String(dateStr || '').replace(/-/g, '')
if (compact.length !== 8) return ''
@@ -28,13 +30,34 @@ export function addDaysYmd(dateStr: string, days: number): string {
return `${yy}${mm}${dd}`
}
/** 分期应缴费时间:第 1 期=开始时间,第 2 期起在按月递增基础上提前 10 天 */
/** 整数部分按日历加月,小数部分按 30 天/月换算为天数 */
export function addIntervalMonthsYmd(dateStr: string, totalMonths: number): string {
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
}
/** 分期应缴费时间:第 1 期=开始时间,第 2 期起在间隔递增基础上提前 10 天 */
export function calcFqInstallmentDueDate(
startDate: string,
index: number,
intervalMonths: number
): string {
const base = addMonthsYmd(startDate, index * intervalMonths)
const base = addIntervalMonthsYmd(startDate, index * intervalMonths)
if (index >= 1) {
return addDaysYmd(base, -10)
}
+80 -5
View File
@@ -1,17 +1,92 @@
/** 未设置头像时使用 `public/avatart.jpg`(根路径 `/avatart.jpg`) */
export const DEFAULT_USER_AVATAR_URL = '/avatart.jpg'
/** 未设置头像时使用 `public/avatart.jpg` */
function joinPublicAsset(path: string): string {
const base = import.meta.env.BASE_URL || '/'
const prefix = base.endsWith('/') ? base : `${base}/`
return `${prefix}${path.replace(/^\//, '')}`
}
export const DEFAULT_USER_AVATAR_URL = joinPublicAsset('avatart.jpg')
/** 本地/部署环境由 .env 配置;docker 默认走 Nginx `/minio/zkxw` 反代 */
const minioBaseUrl = (import.meta.env.VITE_MINIO_BASE_URL || '/minio/zkxw').replace(/\/$/, '')
/** @deprecated 各角色默认已统一为默认头像图,保留参数仅为兼容旧调用 */
export function defaultAvatarPathByRole(_role: string | undefined): string {
return DEFAULT_USER_AVATAR_URL
}
/** 展示用头像:`avatarUrl` 非空则用其值,否则用默认图 */
/**
* 将库中头像值规范为存储相对路径(如 avatar/202606/04/xxx.jpeg)。
* 兼容历史数据里写入的完整 URL(如 https://www.chengyantek.cn/zkxw/avatar/...)。
*/
export function normalizeAvatarStorageKey(raw: string | null | undefined): string {
const u = raw != null ? String(raw).trim() : ''
if (!u) return ''
if (!/^https?:\/\//i.test(u) && !u.startsWith('/')) return u
const marker = '/avatar/'
const lower = u.toLowerCase()
const idx = lower.indexOf(marker)
if (idx >= 0) {
return `avatar/${u.slice(idx + marker.length)}`
}
if (u.startsWith('/')) {
const parts = u.split('/').filter(Boolean)
const avatarIdx = parts.findIndex((p) => p === 'avatar')
if (avatarIdx >= 0) return parts.slice(avatarIdx).join('/')
}
return u
}
/** 从用户信息对象读取原始头像字段(兼容 avatar / avatarUrl) */
export function pickUserAvatarRaw(
source: { avatarUrl?: string | null; avatar?: string | null } | null | undefined
): string {
if (!source) return ''
const raw = source.avatarUrl ?? source.avatar
return normalizeAvatarStorageKey(raw != null ? String(raw).trim() : '')
}
/** 是否已设置自定义头像(非空即视为已设置) */
export function hasCustomUserAvatar(avatarUrl: string | null | undefined): boolean {
return Boolean(normalizeAvatarStorageKey(avatarUrl))
}
/** 解析上传接口返回的图片地址 */
export function pickUploadAvatarUrl(res: unknown): string {
if (res == null) return ''
if (typeof res === 'string') return normalizeAvatarStorageKey(res)
const record = res as Record<string, unknown>
const nested = record.data as Record<string, unknown> | undefined
const raw =
record.url ?? record.fileUrl ?? record.file_url ?? nested?.url ?? nested?.fileUrl
return typeof raw === 'string' ? normalizeAvatarStorageKey(raw) : ''
}
/** MinIO 相对路径或完整 URL → 浏览器可访问地址 */
export function resolveStorageFileUrl(url: string | null | undefined): string {
const key = normalizeAvatarStorageKey(url)
if (!key) return ''
if (/^https?:\/\//i.test(key)) return key
if (key.startsWith('/')) return key
return `${minioBaseUrl}/${key.replace(/^\//, '')}`
}
/** 展示用头像:优先用户表头像,否则默认图 */
export function resolveUserAvatarUrl(
avatarUrl: string | null | undefined,
role: string | undefined
): string {
const u = avatarUrl != null ? String(avatarUrl).trim() : ''
if (u) return u
const key = normalizeAvatarStorageKey(avatarUrl)
if (key) return resolveStorageFileUrl(key)
return defaultAvatarPathByRole(role)
}
/** 从用户对象直接得到展示用头像 URL */
export function resolveUserAvatarFromInfo(
source: { avatarUrl?: string | null; avatar?: string | null; role?: string } | null | undefined
): string {
return resolveUserAvatarUrl(pickUserAvatarRaw(source), source?.role)
}