Files
airz-uniapp/packages/finance/services/matches.uts
T
hujun c95482a0f3 feat: 实现协议全文与同意记录功能
新增协议全文后台管理、版本化发布及本人同意记录功能,支持可撤销授权;完善资料与授权相关边界提示,调整评估提交时的资料与授权校验逻辑,同步更新相关页面文案与路由。
2026-10-14 14:53:13 +08:00

159 lines
19 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { revisionParams } from './records.uts'
import { requestJson } from '@/services/api.uts'
import { API_BASE_URL } from '@/services/config.uts'
import { yuanToWan } from '@/services/money.uts'
export type ProductMaterial = { name: string, note: string, required: boolean, objects: string }
export type ProductView = { logo: string, materialList: ProductMaterial[], consultPhone: string, repayment: string, channel: string, approval: string, id: string, version: number, name: string, bank: string, type: string, demo: boolean, amount: string, rate: string, term: string, objects: string, regions: string, valid: string, materials: string, source: string, sourceRef: string, checkedOn: string, conditions: string[] }
export type MatchReason = { id: string, text: string, evidence: string[], fields: string[], explanation: string }
export type MatchDetail = { assessmentId: string, revisionId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, item: MatchView }
export type MatchView = { score: number | null, id: string, state: string, stateLabel: string, availability: string, availabilityLabel: string, product: ProductView, positive: MatchReason[], blocking: MatchReason[], cautions: MatchReason[], aiText: string, aiStatus: string, aiCanRetry: boolean, aiCanRequest: boolean, aiGeneratedAt: string }
export type MatchPage = { assessmentId: string, revisionId: string, resultVersion: number, snapshotId: string, ruleVersion: string, orderingVersion: string, status: string, message: string, eligible: string, ineligible: string, unknown: string, items: MatchView[], hasMore: boolean, cursor: string }
function text(data: UTSJSONObject, key: string): string { return data.getString(key) ?? '' }
export function validRecordId(value: string): boolean { return /^[1-9][0-9]{0,9}$/.test(value) }
export function eligibilityLabel(value: string): string { return value == 'eligible' ? '符合已知条件' : (value == 'ineligible' ? '条件不符' : '待确认') }
export function hasProductValue(value: string): boolean { return value.trim().length > 0 && !['未提供', '待核实', '—'].includes(value.trim()) }
export function matchPercentage(state: string, score: number | null): string { return state == 'eligible' && score != null ? score.toString() + '%' : '' }
const fieldNames: UTSJSONObject = { requestedAmountYuan: '融资金额', requestedTermMonths: '融资期限', applicantType: '申请主体', purposeCode: '资金用途', cityCode: '所在城市', monthlyIncome: '月收入', annualRevenue: '年营收', operatingMonths: '经营月数', totalDebtBalance: '贷款余额', monthlyLoanPayment: '每月还款额', creditLoanCount: '贷款笔数', creditCardCount: '信用卡数量', creditCardTotalLimit: '信用卡总额度', creditCardUsedLimit: '信用卡已用额度', socialSecurity: '社保情况', housingFund: '公积金情况', house: '房产情况', car: '车辆情况', establishedMonths: '企业成立月数', annualInvoiceAmount: '年开票金额', taxPaymentMonths: '连续纳税月数', taxpayerGrade: '纳税等级', enterpriseCreditStatus: '企业信用状态', annualRevenueGrowthPercent: '年营收增长率', debtRatioPercent: '负债率', cashFlowStability: '经营流水稳定性', industryCategory: '行业类别' }
const labels: UTSJSONObject = { personal: '个人', enterprise: '企业', daily_consumption: '日常消费', education: '教育进修', home_improvement: '家居装修', personal_business: '个体经营周转', working_capital: '经营周转', inventory_purchase: '采购备货', equipment_purchase: '设备购置', business_expansion: '经营扩展', other: '其他用途', none: '无', under_six_months: '不足六个月', six_months_plus: '六个月及以上', yes: '有', no: '无', normal: '正常', abnormal: '异常', stable: '稳定', unstable: '不稳定', manufacturing: '制造业', trade: '贸易', services: '服务业', construction: '建筑业', agriculture: '农业', finance: '金融业', real_estate: '房地产业', '440300': '深圳市', '440100': '广州市', '110100': '北京市', '310100': '上海市', '330100': '杭州市' }
const moneyFields = ['requestedAmountYuan','monthlyIncome','annualRevenue','totalDebtBalance','monthlyLoanPayment','creditCardTotalLimit','creditCardUsedLimit','annualInvoiceAmount']
function valueText(value: any | null, field: string): string {
if (value == null) return '未提供'
const raw = value.toString()
if (moneyFields.includes(field)) return yuanToWan(raw) + ' 万元'
if (field == 'requestedTermMonths' || field == 'operatingMonths' || field == 'establishedMonths' || field == 'taxPaymentMonths') return raw + ' 个月'
if (field == 'debtRatioPercent' || field == 'annualRevenueGrowthPercent') return raw + '%'
return labels.getString(raw) ?? raw
}
function ruleText(rule: UTSJSONObject): string {
const field = text(rule, 'field'); const op = text(rule, 'operator')
const prefix = fieldNames.getString(field) ?? '条件'
let expected = ''
if (op == 'in' || op == 'between') {
const values = rule['value'] as any[]
expected = values.map((value: any): string => valueText(value, field)).join(op == 'between' ? ' 至 ' : '、')
} else expected = valueText(rule['value'], field)
const operation = op == 'gte' ? '不少于 ' : (op == 'lte' ? '不超过 ' : (op == 'in' ? '属于 ' : ''))
return prefix + ':' + operation + expected + (text(rule, 'requiredSource') == 'verified' ? '(需核验依据)' : '')
}
function rateText(value: string): string { return value.indexOf('.') >= 0 ? value.replace(/0+$/, '').replace(/\.$/, '') : value }
function validityText(data: UTSJSONObject): string {
const from = text(data, 'validFrom'); const until = text(data, 'validUntil')
if (from.length == 0) return until.length == 0 ? '未设置有效期' : '有效至 ' + until
return until.length == 0 ? from + ' 起生效' : from + ' 至 ' + until
}
function rangeText(min: string, max: string, unit: string): string {
if (min.length == 0) return max.length == 0 ? '未提供' : '最高 ' + max + unit
if (max.length == 0) return '最低 ' + min + unit
return (min == max ? min : min + '–' + max) + unit
}
function productView(data: UTSJSONObject, publicMode: boolean): ProductView {
const source = data['source'] as UTSJSONObject
const objects = data['applicantTypes'] as string[]; const regions = data['regions'] as string[]
const conditions: string[] = []
if (publicMode) {
const groups = data['rulesByApplicantType'] as UTSJSONObject
objects.forEach((type: string) => {
(groups[type] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push((labels.getString(type) ?? type) + ' · ' + ruleText(r)) })
})
} else (data['rules'] as UTSJSONObject[]).forEach((r: UTSJSONObject) => { conditions.push(ruleText(r)) })
const rawLogo = text(data, 'bankLogo')
const logo = /^https:\/\/[^\s<>"']+$/.test(rawLogo) ? rawLogo : (/^\/uploads\/[a-zA-Z0-9/_.-]+$/.test(rawLogo) ? API_BASE_URL.replace(/\/api\/v1\/?$/, '') + rawLogo : '')
const materialList = ((data['materials'] as UTSJSONObject[] | null) ?? []).map((item: UTSJSONObject): ProductMaterial => {
return { name: text(item, 'name'), note: text(item, 'note'), required: item.getBoolean('required') ?? false, objects: ((item['applicantTypes'] as string[] | null) ?? []).map((v: string): string => labels.getString(v) ?? v).join(' / ') }
})
const rawPhone = text(data, 'consultPhone')
return { logo, materialList, consultPhone: /^[+0-9][0-9 -]{4,29}$/.test(rawPhone) ? rawPhone : '', repayment: text(data, 'repaymentMethod') || '未提供', channel: text(data, 'channel') || '未提供', approval: text(data, 'approvalDuration') || '未提供', id: text(data, 'productId'), version: data.getNumber('productVersion') ?? 0, name: text(data, 'productName'), bank: text(data, 'bankName'), type: text(data, 'productType'), demo: data.getBoolean('isDemo') ?? false,
amount: text(data, 'expectation') || rangeText(text(data, 'amountMinYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMinYuan')), text(data, 'amountMaxYuan').length == 0 ? '' : yuanToWan(text(data, 'amountMaxYuan')), ' 万元'),
rate: rangeText(rateText(text(data, 'annualRateMinPercent')), rateText(text(data, 'annualRateMaxPercent')), '%'),
term: text(data, 'quota') || rangeText(data.getNumber('termMinMonths')?.toString() ?? '', data.getNumber('termMaxMonths')?.toString() ?? '', ' 个月'),
objects: objects.map((v: string): string => labels.getString(v) ?? v).join(' / '), regions: text(data, 'regionMode') == 'unverified' ? '待核实' : (regions.length == 0 ? '全国' : regions.map((v: string): string => labels.getString(v) ?? v).join('、')),
valid: validityText(data), materials: text(data, 'information').length > 0 ? text(data, 'information') : '未提供',
// 内部引用用于定位资料来源,不把测试环境标识当作面向用户的产品说明。
source: [text(source, 'publisher'), text(source, 'title')].filter((value: string): boolean => value.length > 0).join(' · ') || '待核实', sourceRef: text(source, 'reference').startsWith('demo:airz-') ? '产品规则档案' : (text(source, 'reference') || '未提供'), checkedOn: text(source, 'checkedOn') || '未提供', conditions }
}
function matchView(data: UTSJSONObject): MatchView {
const snapshot = data['productSnapshot'] as UTSJSONObject
const productId = text(data, 'productId'); const version = data.getNumber('productVersion') ?? 0
if (!validRecordId(productId) || text(snapshot, 'productId') != productId || version < 1 || Math.floor(version) != version || snapshot.getNumber('productVersion') != version || !['eligible','ineligible','unknown'].includes(text(data, 'eligibility')) || !['available','unavailable','unknown'].includes(text(data, 'currentAvailability'))) throw new Error('产品冻结资料不完整,请重新加载。')
const evidence = data['evidence'] as UTSJSONObject[]
const ai = data['aiExplanation'] as UTSJSONObject
const explanations = (ai['reasonExplanations'] as UTSJSONObject[] | null) ?? []
const reasons = (key: string): MatchReason[] => (data[key] as UTSJSONObject[]).map((r: UTSJSONObject): MatchReason => {
const reasonFields: string[] = []
const reasonText = ['AI_CONDITION_PASS', 'AI_CONDITION_FAIL'].includes(text(r, 'code')) ? text(r, 'text').replace('(根据自填资料判断)', '') : text(r, 'text')
const lines = (r['evidenceRefs'] as string[]).map((ref: string): string => {
const e = evidence.find((item: UTSJSONObject): boolean => text(item, 'evidenceId') == ref)
if (e == null) return '证据暂不可读取'
const fields = e['fieldRefs'] as string[]; const field = fields.length > 0 ? fields[0] : ''
fields.forEach((name: string) => { if (!reasonFields.includes(name)) reasonFields.push(name) })
if (text(e, 'source') == 'ai_self_reported') {
if (e['actual'] != null && fieldNames.getString(field) != null) return fieldNames.getString(field)! + ':' + valueText(e['actual'], field)
const names = fields.map((name: string): string => fieldNames.getString(name) ?? '').filter((name: string): boolean => name.length > 0)
return names.length > 0 ? '关联资料:' + names.join('、') : reasonText
}
const source = text(e, 'source') == 'verified' ? '外部核验' : (text(e, 'source') == 'product_snapshot' ? '产品快照' : (text(e, 'source') == 'self_reported' ? '用户自填,未经核验' : '来源未知'))
return (fieldNames.getString(field) ?? field) + ':' + valueText(e['actual'], field) + ' · ' + source
})
const explanation = explanations.find((item: UTSJSONObject): boolean => text(item, 'reasonId') == text(r, 'reasonId'))
return { id: text(r, 'reasonId'), text: reasonText, evidence: lines, fields: reasonFields, explanation: text(ai, 'status') == 'completed' && explanation != null ? text(explanation, 'text') : '' }
})
const availability = text(data, 'currentAvailability')
const score = data.getNumber('score')
if (score != null && (Math.floor(score) != score || score < 0 || score > 100 || text(data, 'scorePolicyVersion') != 'product-soft-score-v1')) throw new Error('匹配度资料无效,请重新加载。')
const pending = evidence.filter((item: UTSJSONObject): boolean => text(item, 'kind') == 'hard' && text(item, 'outcome') == 'unknown')
const missingData = text(data, 'eligibility') == 'unknown' && pending.length > 0 && pending.every((item: UTSJSONObject): boolean => ['FACT_MISSING', 'AI_CONDITION_UNKNOWN'].includes(text(item, 'reasonCode')))
return { score, id: text(data, 'productId'), state: text(data, 'eligibility'), stateLabel: missingData ? '资料不足' : eligibilityLabel(text(data, 'eligibility')), availability,
availabilityLabel: availability == 'available' ? '当前产品可查看' : (availability == 'unavailable' ? '当前产品已下架' : '当前产品状态暂无法确认'),
product: productView(data['productSnapshot'] as UTSJSONObject, false), positive: reasons('positiveReasons'), blocking: reasons('blockingReasons'), cautions: reasons('cautions'),
aiText: text(ai, 'status') == 'completed' ? text(ai, 'text') : (text(ai, 'status') == 'failed' ? 'AI解释暂不可用,已保留全部规则依据。' : '当前展示规则依据,尚未生成单产品AI解释。'),
aiStatus: text(ai, 'status'), aiCanRetry: ai.getBoolean('canRetry') ?? false, aiCanRequest: data.getBoolean('canRequestExplanation') ?? false, aiGeneratedAt: text(ai, 'generatedAt') }
}
export async function requestProductExplanation(id: string, productId: string, requestId: string, revisionId: string = ''): Promise<MatchView> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId) + '/explanation', 'POST', { ...revisionParams(revisionId), requestId })
return detailRecord(data, id, productId, revisionId).item
}
export async function fetchMatchPage(id: string, group: string, cursor: string, revisionId: string = ''): Promise<MatchPage> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches', 'GET', { ...revisionParams(revisionId), eligibility: group, limit: '3', cursor })
const matching = data['matching'] as UTSJSONObject; const status = text(matching, 'status')
const resolvedRevision = text(data, 'revisionId'); const version = data.getNumber('resultVersion') ?? 0
if (!validRecordId(resolvedRevision) || (revisionId.length > 0 && resolvedRevision != revisionId) || text(data, 'assessmentId') != id || version < 1 || Math.floor(version) != version) throw new Error('匹配结果与本次评估不一致,请重新加载。')
const items = (data['items'] as UTSJSONObject[]).map((v: UTSJSONObject): MatchView => matchView(v))
const hasMore = data.getBoolean('hasMore') ?? false; const nextCursor = text(data, 'nextCursor')
const ids: string[] = []
items.forEach((item: MatchView) => { if (item.state != group || ids.includes(item.id)) throw new Error('匹配分组或产品编号不一致,请重新加载。'); ids.push(item.id) })
if (!['completed','unavailable'].includes(status) || items.length > 3 || (hasMore && (items.length == 0 || nextCursor.length == 0)) || (!hasMore && nextCursor.length > 0) || (status == 'unavailable' && (items.length > 0 || hasMore))) throw new Error('匹配分页状态不完整,请重新加载。')
if (status == 'completed') {
if (text(matching, 'catalogSnapshotId').length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0) throw new Error('匹配快照版本不完整,请重新加载。')
const countKeys = ['candidateCount','ineligibleCount','unknownCount']
countKeys.forEach((key: string) => { const value = matching.getNumber(key); if (value == null || value < 0 || Math.floor(value) != value) throw new Error('匹配统计不完整,请重新加载。') })
}
const reason = text(matching, 'reasonCode')
const count = (key: string): string => { const v = matching.getNumber(key); return v == null ? '—' : v.toString() }
const message = status == 'completed' ? (text(matching, 'orderingPolicyVersion') == 'product-score-descending-v1' ? '按本次条件匹配度排序,匹配度不代表获批概率。' : '按本次评估时的产品条件判断,展示顺序不代表优劣。') : (reason == 'LEGACY_INPUT' ? '旧版报告未执行产品匹配,可重新评估获取新的结果。' : (reason == 'MATCH_RULES_UNPUBLISHED' ? '本次评估尚未启用产品匹配规则。' : '本次评估没有可用产品目录,未执行产品匹配。'))
return { assessmentId: id, revisionId: resolvedRevision, resultVersion: version, snapshotId: text(matching, 'catalogSnapshotId'), ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), status, message, eligible: count('candidateCount'), ineligible: count('ineligibleCount'), unknown: count('unknownCount'), items, hasMore, cursor: nextCursor }
}
export async function fetchMatchDetail(id: string, productId: string, revisionId: string = ''): Promise<MatchView> {
return (await fetchMatchDetailRecord(id, productId, revisionId)).item
}
export async function fetchMatchDetailRecord(id: string, productId: string, revisionId: string = ''): Promise<MatchDetail> {
const data = await requestJson('/assessments/' + encodeURIComponent(id) + '/matches/' + encodeURIComponent(productId), 'GET', revisionParams(revisionId))
return detailRecord(data, id, productId, revisionId)
}
function detailRecord(data: UTSJSONObject, id: string, productId: string, revisionId: string): MatchDetail {
const matching = data['matching'] as UTSJSONObject
const item = matchView(data['item'] as UTSJSONObject)
const version = data.getNumber('resultVersion') ?? 0
const snapshotId = text(matching, 'catalogSnapshotId')
// URL只表达选择意图;读取后再次检查归属和冻结版本,不能用公共资料补齐失败列。
if (!validRecordId(text(data, 'revisionId')) || (revisionId.length > 0 && text(data, 'revisionId') != revisionId) || text(data, 'assessmentId') != id || item.id != productId || item.product.id != productId || version < 1 || Math.floor(version) != version || snapshotId.length == 0 || text(matching, 'ruleVersion').length == 0 || text(matching, 'orderingPolicyVersion').length == 0 || text(matching, 'status') != 'completed') throw new Error('产品不属于本次冻结匹配结果,请返回重新选择。')
return { assessmentId: id, revisionId: text(data, 'revisionId'), resultVersion: version, snapshotId, ruleVersion: text(matching, 'ruleVersion'), orderingVersion: text(matching, 'orderingPolicyVersion'), item }
}
export async function fetchPublicProduct(id: string): Promise<ProductView> {
const result = productView(await requestJson('/products/' + encodeURIComponent(id), 'GET', new UTSJSONObject()), true)
if (result.id != id || result.version < 1 || Math.floor(result.version) != result.version) throw new Error('产品资料与所选产品不一致,请重新加载。')
return result
}