feat(ai): add tenant-safe hierarchical expense learning
This commit is contained in:
@@ -19,6 +19,43 @@ const AI_APPLICATION_MEMORY_FIELD_LABELS = {
|
||||
transport_mode: '出行方式',
|
||||
transportMode: '出行方式'
|
||||
}
|
||||
const AI_APPLICATION_MEMORY_SCOPE_DEFAULTS = {
|
||||
enterprise: {
|
||||
label: '企业制度',
|
||||
priority: 300,
|
||||
source: 'admin_managed_org'
|
||||
},
|
||||
department: {
|
||||
label: '部门基线',
|
||||
priority: 200,
|
||||
source: 'admin_managed_org'
|
||||
},
|
||||
user: {
|
||||
label: '个人偏好',
|
||||
priority: 100,
|
||||
source: 'verified_user_history'
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOptionalNumber(value) {
|
||||
if (value === '' || value === null || value === undefined) return null
|
||||
const normalized = Number(value)
|
||||
return Number.isFinite(normalized) ? normalized : null
|
||||
}
|
||||
|
||||
function normalizeBoolean(value, fallback = false) {
|
||||
if (value === true || value === 1 || value === 'true' || value === '1') return true
|
||||
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function normalizeMemoryConflicts(value) {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value.filter((item) => {
|
||||
if (item && typeof item === 'object') return true
|
||||
return Boolean(normalizeText(item))
|
||||
})
|
||||
}
|
||||
|
||||
export function normalizeAiApplicationMemory(item = {}) {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
@@ -26,6 +63,12 @@ export function normalizeAiApplicationMemory(item = {}) {
|
||||
const fieldKey = normalizeText(item.fieldKey || item.field_key)
|
||||
const rawStatus = normalizeText(item.status).toLowerCase()
|
||||
const status = rawStatus === 'active' ? 'applied' : rawStatus
|
||||
const scopeType = normalizeText(item.scopeType || item.scope_type).toLowerCase() || 'user'
|
||||
const scopeDefaults = AI_APPLICATION_MEMORY_SCOPE_DEFAULTS[scopeType]
|
||||
|| AI_APPLICATION_MEMORY_SCOPE_DEFAULTS.user
|
||||
const explicitCanRevoke = Object.prototype.hasOwnProperty.call(item, 'canRevoke')
|
||||
? item.canRevoke
|
||||
: item.can_revoke
|
||||
if (!memoryId || !fieldKey || !status) return null
|
||||
return {
|
||||
memoryId,
|
||||
@@ -39,6 +82,17 @@ export function normalizeAiApplicationMemory(item = {}) {
|
||||
evidenceCount: Number(item.evidenceCount ?? item.evidence_count ?? 0) || 0,
|
||||
approvedEvidenceCount:
|
||||
Number(item.approvedEvidenceCount ?? item.approved_evidence_count ?? 0) || 0,
|
||||
scopeType,
|
||||
scopeId: normalizeText(item.scopeId || item.scope_id),
|
||||
scopeLabel: normalizeText(item.scopeLabel || item.scope_label) || scopeDefaults.label,
|
||||
source: normalizeText(item.source) || scopeDefaults.source,
|
||||
priority:
|
||||
normalizeOptionalNumber(item.priority) ?? scopeDefaults.priority,
|
||||
effectiveConfidence: normalizeOptionalNumber(
|
||||
item.effectiveConfidence ?? item.effective_confidence
|
||||
),
|
||||
conflicts: normalizeMemoryConflicts(item.conflicts),
|
||||
canRevoke: normalizeBoolean(explicitCanRevoke, scopeType === 'user'),
|
||||
message: normalizeText(item.message)
|
||||
}
|
||||
}
|
||||
|
||||
167
web/src/services/expenseApplicationMemories.js
Normal file
167
web/src/services/expenseApplicationMemories.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { apiRequest } from './api.js'
|
||||
|
||||
export const EXPENSE_MEMORY_TRANSPORT_OPTIONS = ['飞机', '火车', '轮船']
|
||||
export const EXPENSE_MEMORY_MIN_DAYS = 30
|
||||
export const EXPENSE_MEMORY_MAX_DAYS = 365
|
||||
|
||||
function normalizeText(value) {
|
||||
return String(value || '').trim()
|
||||
}
|
||||
|
||||
function normalizeInteger(value, fallback = 0) {
|
||||
const normalized = Number.parseInt(String(value ?? ''), 10)
|
||||
return Number.isFinite(normalized) ? normalized : fallback
|
||||
}
|
||||
|
||||
function requireReason(value) {
|
||||
const reason = normalizeText(value)
|
||||
if (!reason) throw new Error('请填写操作理由。')
|
||||
return reason
|
||||
}
|
||||
|
||||
function requireTransportValue(value) {
|
||||
const normalized = normalizeText(value)
|
||||
if (!EXPENSE_MEMORY_TRANSPORT_OPTIONS.includes(normalized)) {
|
||||
throw new Error('出行方式仅支持飞机、火车或轮船。')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireExpiresInDays(value) {
|
||||
const normalized = normalizeInteger(value)
|
||||
if (normalized < EXPENSE_MEMORY_MIN_DAYS || normalized > EXPENSE_MEMORY_MAX_DAYS) {
|
||||
throw new Error('有效期必须在 30 到 365 天之间。')
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireExpectedGeneration(value) {
|
||||
const normalized = normalizeInteger(value)
|
||||
if (normalized < 1) throw new Error('缺少有效的记忆版本。')
|
||||
return normalized
|
||||
}
|
||||
|
||||
function requireRequestId(value) {
|
||||
const normalized = normalizeText(value)
|
||||
if (normalized.length < 8) throw new Error('缺少有效的幂等请求标识。')
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeOrganizationExpenseMemory(item = {}) {
|
||||
if (!item || typeof item !== 'object') return null
|
||||
const id = normalizeText(item.id)
|
||||
const scopeType = normalizeText(item.scopeType || item.scope_type).toLowerCase()
|
||||
if (!id || !['enterprise', 'department'].includes(scopeType)) return null
|
||||
const originType = normalizeText(item.originType || item.origin_type)
|
||||
return {
|
||||
id,
|
||||
fieldKey: normalizeText(item.fieldKey || item.field_key) || 'transport_mode',
|
||||
value: normalizeText(item.value),
|
||||
status: normalizeText(item.status).toLowerCase(),
|
||||
scopeType,
|
||||
scopeId: normalizeText(item.scopeId || item.scope_id),
|
||||
scopeLabel: normalizeText(item.scopeLabel || item.scope_label)
|
||||
|| (scopeType === 'enterprise' ? '企业统一规则' : '部门规则'),
|
||||
source: normalizeText(item.source),
|
||||
originType,
|
||||
sourceLabel: originType === 'admin_managed' ? '管理员显式维护' : '组织规则',
|
||||
generation: Math.max(1, normalizeInteger(item.generation, 1)),
|
||||
policyVersion: normalizeText(item.policyVersion || item.policy_version),
|
||||
validFrom: normalizeText(item.validFrom || item.valid_from),
|
||||
expiresAt: normalizeText(item.expiresAt || item.expires_at),
|
||||
managedAt: normalizeText(item.managedAt || item.managed_at),
|
||||
canRevoke: Boolean(item.canRevoke ?? item.can_revoke),
|
||||
createdAt: normalizeText(item.createdAt || item.created_at),
|
||||
updatedAt: normalizeText(item.updatedAt || item.updated_at)
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeOrganizationExpenseMemories(payload = {}) {
|
||||
const items = Array.isArray(payload) ? payload : payload?.items
|
||||
return (Array.isArray(items) ? items : [])
|
||||
.map((item) => normalizeOrganizationExpenseMemory(item))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
export function buildOrganizationExpenseMemoryCreatePayload(input = {}) {
|
||||
const scopeType = normalizeText(input.scopeType || input.scope_type).toLowerCase()
|
||||
if (!['enterprise', 'department'].includes(scopeType)) {
|
||||
throw new Error('请选择企业或部门作用域。')
|
||||
}
|
||||
const payload = {
|
||||
scope_type: scopeType,
|
||||
value: requireTransportValue(input.value),
|
||||
expires_in_days: requireExpiresInDays(input.expiresInDays ?? input.expires_in_days),
|
||||
reason: requireReason(input.reason),
|
||||
request_id: requireRequestId(input.requestId || input.request_id)
|
||||
}
|
||||
if (scopeType === 'department') {
|
||||
const scopeId = normalizeText(input.scopeId || input.scope_id)
|
||||
if (!scopeId) throw new Error('请选择要适用的部门。')
|
||||
payload.scope_id = scopeId
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
export function buildOrganizationExpenseMemoryUpdatePayload(input = {}) {
|
||||
return {
|
||||
value: requireTransportValue(input.value),
|
||||
expires_in_days: requireExpiresInDays(input.expiresInDays ?? input.expires_in_days),
|
||||
expected_generation: requireExpectedGeneration(
|
||||
input.expectedGeneration ?? input.expected_generation
|
||||
),
|
||||
reason: requireReason(input.reason),
|
||||
request_id: requireRequestId(input.requestId || input.request_id)
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOrganizationExpenseMemoryRevokePayload(input = {}) {
|
||||
return {
|
||||
expected_generation: requireExpectedGeneration(
|
||||
input.expectedGeneration ?? input.expected_generation
|
||||
),
|
||||
reason: requireReason(input.reason),
|
||||
request_id: requireRequestId(input.requestId || input.request_id)
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOrganizationExpenseMemories(options = {}) {
|
||||
const payload = await apiRequest('/expense-application-memories/organization', options)
|
||||
return normalizeOrganizationExpenseMemories(payload)
|
||||
}
|
||||
|
||||
export async function createOrganizationExpenseMemory(input = {}, options = {}) {
|
||||
const payload = await apiRequest('/expense-application-memories/organization', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildOrganizationExpenseMemoryCreatePayload(input)),
|
||||
...options
|
||||
})
|
||||
return normalizeOrganizationExpenseMemory(payload)
|
||||
}
|
||||
|
||||
export async function updateOrganizationExpenseMemory(memoryId, input = {}, options = {}) {
|
||||
const id = normalizeText(memoryId)
|
||||
if (!id) throw new Error('缺少要更新的记忆标识。')
|
||||
const payload = await apiRequest(
|
||||
`/expense-application-memories/organization/${encodeURIComponent(id)}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(buildOrganizationExpenseMemoryUpdatePayload(input)),
|
||||
...options
|
||||
}
|
||||
)
|
||||
return normalizeOrganizationExpenseMemory(payload)
|
||||
}
|
||||
|
||||
export function revokeOrganizationExpenseMemory(memoryId, input = {}, options = {}) {
|
||||
const id = normalizeText(memoryId)
|
||||
if (!id) return Promise.reject(new Error('缺少要撤销的记忆标识。'))
|
||||
return apiRequest(
|
||||
`/expense-application-memories/organization/${encodeURIComponent(id)}/revoke`,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(buildOrganizationExpenseMemoryRevokePayload(input)),
|
||||
...options
|
||||
}
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user