import { expertList, getMajorDictEntries, getMajorIdAliasMap, getServiceDictById, taskList } from '@/sql' import { roundTo, sumByNumber, toDecimal, toFiniteNumberOrNull } from '@/lib/decimal' import { getScaleBudgetFee } from '@/lib/pricingScaleFee' import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail' import { isInvestScaleSingleTotalService } from '@/lib/servicePricing' import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing' import { useKvStore } from '@/pinia/kv' interface StoredDetailRowsState { detailRows?: T[] totalAmount?: number | null } interface StoredFactorState { detailRows?: Array<{ id: string standardFactor?: number | null budgetValue?: number | null }> } type MaybeNumber = number | null | undefined const sumByNumberNullable = (list: T[], pick: (item: T) => MaybeNumber): number | null => { let hasValid = false const total = sumByNumber(list, item => { const value = toFiniteNumberOrNull(pick(item)) if (value == null) return null hasValid = true return value }) return hasValid ? total : null } const getOnlyCostScaleSummaryAmount = ( rows?: Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount?: unknown ) => { if (typeof totalAmount === 'number' && Number.isFinite(totalAmount)) return totalAmount const summaryRow = (rows || []).find(row => row?.isGroupRow === true) if (typeof summaryRow?.amount === 'number' && Number.isFinite(summaryRow.amount)) return summaryRow.amount return null } interface ScaleRow { id: string amount: number | null landArea: number | null benchmarkBudgetBasicChecked: boolean benchmarkBudgetOptionalChecked: boolean consultCategoryFactor: number | null majorFactor: number | null workStageFactor: number | null workRatio: number | null } interface WorkloadRow { id: string conversion: number | null workload: number | null basicFee: number | null budgetAdoptedUnitPrice: number | null consultCategoryFactor: number | null } interface HourlyRow { id: string adoptedBudgetUnitPrice: number | null personnelCount: number | null workdayCount: number | null } interface MajorLite { code: string defCoe: number | null hasCost?: boolean hasArea?: boolean industryId?: string | number | null } interface ServiceLite { defCoe: number | null investScaleSingleTotal?: boolean | null } interface TaskLite { serviceID: number conversion: number | null defPrice: number | null } interface ExpertLite { defPrice: number | null manageCoe: number | null } interface XmBaseInfoState { projectIndustry?: string } export interface PricingMethodTotals { investScale: number | null landScale: number | null workload: number | null hourly: number | null } interface PricingMethodTotalsOptions { excludeInvestmentCostAndAreaRows?: boolean } interface PricingMethodDetailDbKeys { investScale: string landScale: string workload: string hourly: string } interface PricingMethodDefaultDetailRows { investScale: unknown[] landScale: unknown[] workload: unknown[] hourly: unknown[] } interface PricingMethodDefaultBuildContext { htData: StoredDetailRowsState | null consultCategoryFactorMap: Map majorFactorMap: Map industryId: string excludeInvestmentCostAndAreaRows: boolean } const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' const SERVICE_PRICING_METHODS: ServicePricingMethod[] = ['investScale', 'landScale', 'workload', 'hourly'] const getZxFwStoreSafely = () => { try { return useZxFwPricingStore() } catch { return null } } const getKvStoreSafely = () => { try { return useKvStore() } catch { return null } } const kvGetItem = async (key: string): Promise => { const store = getKvStoreSafely() if (!store) return null return store.getItem(key) } const kvSetItem = async (key: string, value: T): Promise => { const store = getKvStoreSafely() if (!store) return await store.setItem(key, value) } const toStoredDetailRowsState = (state: { detailRows?: TRow[] } | null | undefined): StoredDetailRowsState | null => { if (!state || !Array.isArray(state.detailRows)) return null return { detailRows: JSON.parse(JSON.stringify(state.detailRows)) } } const hasOwn = (obj: unknown, key: string) => Object.prototype.hasOwnProperty.call(obj || {}, key) const isGroupScaleRow = (row: unknown) => Boolean(row && typeof row === 'object' && (row as Record).isGroupRow === true) const stripGroupScaleRows = (rows: TRow[] | undefined): TRow[] => (rows || []).filter(row => !isGroupScaleRow(row)) const getRowNumberOrFallback = ( row: Record | undefined, key: string, fallback: number | null ) => { if (!row) return fallback const value = toFiniteNumberOrNull(row[key]) if (value != null) return value return hasOwn(row, key) ? null : fallback } const toRowMap = (rows?: TRow[]) => { const map = new Map() for (const row of rows || []) { map.set(String(row.id), row) } return map } const parseScopedMajorId = (value: unknown) => { const raw = String(value || '').trim() const scoped = /^\d+::(.+)$/.exec(raw) return (scoped ? String(scoped[1] || '').trim() : raw) || raw } const hasScopedScaleRows = (rows?: Array>) => (rows || []).some(row => { const id = String(row?.id || '') if (/^\d+::/.test(id)) return true const projectIndex = Number((row as { projectIndex?: unknown })?.projectIndex) return Number.isFinite(projectIndex) && projectIndex > 1 }) const getDefaultConsultCategoryFactor = (serviceId: string | number) => { const service = (getServiceDictById() as Record)[String(serviceId)] return toFiniteNumberOrNull(service?.defCoe) } const usesInvestScaleSingleTotal = (serviceId: string | number) => { const service = (getServiceDictById() as Record)[String(serviceId)] return isInvestScaleSingleTotalService(service) } const majorById = new Map(getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite])) const majorIdAliasMap = getMajorIdAliasMap() const getDefaultMajorFactorById = (id: string) => { const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id const major = majorById.get(resolvedId) return toFiniteNumberOrNull(major?.defCoe) } const isCostMajorById = (id: string) => { const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id const major = majorById.get(resolvedId) if (!major) return false return major.hasCost !== false } const isAreaMajorById = (id: string) => { const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id const major = majorById.get(resolvedId) if (!major) return false return major.hasArea !== false } const isDualScaleMajorById = (id: string) => { const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id const major = majorById.get(resolvedId) if (!major) return false const hasCost = major.hasCost !== false const hasArea = major.hasArea !== false return hasCost && hasArea } const getIndustryMajorEntryByIndustryId = (industryId: string | null | undefined) => { const key = String(industryId || '').trim() if (!key) return null for (const [id, item] of majorById.entries()) { const majorIndustryId = String(item?.industryId ?? '').trim() if (majorIndustryId === key && !String(item?.code || '').includes('-')) { return { id, item } } } return null } const resolveFactorValue = ( row: { budgetValue?: number | null; standardFactor?: number | null } | undefined, fallback: number | null ) => { if (!row) return fallback if (hasOwn(row, 'budgetValue')) { return toFiniteNumberOrNull(row.budgetValue) } if (hasOwn(row, 'standardFactor')) { return toFiniteNumberOrNull(row.standardFactor) } return fallback } const buildConsultCategoryFactorMap = (state: StoredFactorState | null) => { const map = new Map() const serviceDict = getServiceDictById() as Record for (const [id, item] of Object.entries(serviceDict)) { map.set(String(id), toFiniteNumberOrNull(item?.defCoe)) } for (const row of state?.detailRows || []) { if (!row?.id) continue const id = String(row.id) map.set(id, resolveFactorValue(row, map.get(id) ?? null)) } return map } const buildMajorFactorMap = (state: StoredFactorState | null) => { const map = new Map() for (const [id, item] of majorById.entries()) { map.set(String(id), toFiniteNumberOrNull(item?.defCoe)) } for (const row of state?.detailRows || []) { if (!row?.id) continue const rowId = String(row.id) const id = map.has(rowId) ? rowId : majorIdAliasMap.get(rowId) || rowId map.set(id, resolveFactorValue(row, map.get(id) ?? null)) } return map } const getMajorLeafIds = () => getMajorDictEntries() .filter(({ item }) => Boolean(item?.code && String(item.code).includes('-'))) .map(({ id }) => id) const buildDefaultScaleRows = ( serviceId: string | number, consultCategoryFactorMap?: Map, majorFactorMap?: Map ): ScaleRow[] => { const defaultConsultCategoryFactor = consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) return getMajorLeafIds().map(id => ({ id, amount: null, landArea: null, benchmarkBudgetBasicChecked: true, benchmarkBudgetOptionalChecked: true, consultCategoryFactor: defaultConsultCategoryFactor, majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactorById(id), workStageFactor: 1, workRatio: 100 })) } const mergeScaleRows = ( serviceId: string | number, rowsFromDb: Array & Pick> | undefined, consultCategoryFactorMap?: Map, majorFactorMap?: Map ): ScaleRow[] => { const sourceRows = stripGroupScaleRows(rowsFromDb) const dbValueMap = toRowMap(sourceRows) for (const row of sourceRows) { const rowId = String(row.id) const nextId = majorIdAliasMap.get(rowId) if (nextId && !dbValueMap.has(nextId)) { dbValueMap.set(nextId, row as ScaleRow) } } const defaultConsultCategoryFactor = consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap).map(row => { const fromDb = dbValueMap.get(row.id) if (!fromDb) return row const hasConsultCategoryFactor = hasOwn(fromDb, 'consultCategoryFactor') const hasMajorFactor = hasOwn(fromDb, 'majorFactor') const hasWorkStageFactor = hasOwn(fromDb, 'workStageFactor') const hasWorkRatio = hasOwn(fromDb, 'workRatio') return { ...row, amount: toFiniteNumberOrNull(fromDb.amount), landArea: toFiniteNumberOrNull(fromDb.landArea), benchmarkBudgetBasicChecked: typeof (fromDb as { benchmarkBudgetBasicChecked?: unknown }).benchmarkBudgetBasicChecked === 'boolean' ? Boolean((fromDb as { benchmarkBudgetBasicChecked?: unknown }).benchmarkBudgetBasicChecked) : true, benchmarkBudgetOptionalChecked: typeof (fromDb as { benchmarkBudgetOptionalChecked?: unknown }).benchmarkBudgetOptionalChecked === 'boolean' ? Boolean((fromDb as { benchmarkBudgetOptionalChecked?: unknown }).benchmarkBudgetOptionalChecked) : true, consultCategoryFactor: toFiniteNumberOrNull(fromDb.consultCategoryFactor) ?? (hasConsultCategoryFactor ? null : defaultConsultCategoryFactor), majorFactor: toFiniteNumberOrNull(fromDb.majorFactor) ?? (hasMajorFactor ? null : (majorFactorMap?.get(row.id) ?? getDefaultMajorFactorById(row.id))), workStageFactor: toFiniteNumberOrNull((fromDb as Partial).workStageFactor) ?? (hasWorkStageFactor ? null : row.workStageFactor), workRatio: toFiniteNumberOrNull((fromDb as Partial).workRatio) ?? (hasWorkRatio ? null : row.workRatio) } }) } const getInvestmentBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'cost') const getInvestScaleSingleTotalBudgetFee = ( serviceId: string, rowsFromDb: Array> | undefined, consultCategoryFactorMap?: Map, majorFactorMap?: Map, industryId?: string | null, totalAmount?: number | null ) => { const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const rawRows = rowsFromDb || [] const sourceRows = stripGroupScaleRows(rowsFromDb) const defaultConsultCategoryFactor = consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) const defaultMajorFactor = (industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ?? toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ?? 1 // 单行总投资模式支持“按项目行”存储(如 1::majorId、2::majorId),每行需独立计费后求和。 const usePerRowCalculation = sourceRows.some(row => { if (typeof row?.projectIndex === 'number' && Number.isFinite(row.projectIndex)) return true const id = String(row?.id || '') return /^\d+::/.test(id) }) if (usePerRowCalculation) { return sumByNumberNullable(sourceRows, row => { const amount = toFiniteNumberOrNull(row?.amount) if (amount == null) return null return getScaleBudgetFeeByRow({ amount, benchmarkBudgetBasicChecked: typeof row?.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true, benchmarkBudgetOptionalChecked: typeof row?.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true, majorFactor: getRowNumberOrFallback(row, 'majorFactor', defaultMajorFactor), consultCategoryFactor: getRowNumberOrFallback(row, 'consultCategoryFactor', defaultConsultCategoryFactor), workStageFactor: getRowNumberOrFallback(row, 'workStageFactor', 1), workRatio: getRowNumberOrFallback(row, 'workRatio', 100) }, 'cost') }) } const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount) if (resolvedTotalAmount == null) return null const onlyRow = rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) || sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) || sourceRows[0] const consultCategoryFactor = getRowNumberOrFallback(onlyRow, 'consultCategoryFactor', defaultConsultCategoryFactor) const majorFactor = getRowNumberOrFallback(onlyRow, 'majorFactor', defaultMajorFactor) const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1) const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100) return getScaleBudgetFeeByRow({ amount: resolvedTotalAmount, benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true, benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true, majorFactor, consultCategoryFactor, workStageFactor, workRatio }, 'cost') } const buildInvestScaleSingleTotalDetailRows = ( serviceId: string, rowsFromDb: Array> | undefined, consultCategoryFactorMap?: Map, majorFactorMap?: Map, industryId?: string | null, totalAmount?: number | null ) => { const rawRows = rowsFromDb || [] const sourceRows = stripGroupScaleRows(rowsFromDb) const resolvedTotalAmount = getOnlyCostScaleSummaryAmount(rawRows as Array<{ amount?: unknown; isGroupRow?: unknown }>, totalAmount) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID const onlyRow = rawRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) || sourceRows.find(row => String(row?.id || '') === onlyCostRowId) const consultCategoryFactor = getRowNumberOrFallback( onlyRow, 'consultCategoryFactor', consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) ) const majorFactor = getRowNumberOrFallback( onlyRow, 'majorFactor', (industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ?? toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ?? 1 ) const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1) const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100) return [ { id: onlyCostRowId, amount: resolvedTotalAmount, landArea: null, consultCategoryFactor, majorFactor, workStageFactor, workRatio, benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true, benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true } ] } const getLandBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'area') const getTaskEntriesByServiceId = (serviceId: string | number) => Object.entries(taskList as Record) .sort((a, b) => Number(a[0]) - Number(b[0])) .filter(([, task]) => Number(task.serviceID) === Number(serviceId)) const buildDefaultWorkloadRows = ( serviceId: string | number, consultCategoryFactorMap?: Map ): WorkloadRow[] => { const defaultConsultCategoryFactor = consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) return getTaskEntriesByServiceId(serviceId).map(([taskId, task], order) => ({ id: `task-${taskId}-${order}`, conversion: toFiniteNumberOrNull(task.conversion), workload: null, basicFee: null, budgetAdoptedUnitPrice: toFiniteNumberOrNull(task.defPrice), consultCategoryFactor: defaultConsultCategoryFactor })) } const mergeWorkloadRows = ( serviceId: string | number, rowsFromDb: Array & Pick> | undefined, consultCategoryFactorMap?: Map ): WorkloadRow[] => { const dbValueMap = toRowMap(rowsFromDb) return buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap).map(row => { const fromDb = dbValueMap.get(row.id) if (!fromDb) return row return { ...row, workload: toFiniteNumberOrNull(fromDb.workload), basicFee: toFiniteNumberOrNull(fromDb.basicFee), budgetAdoptedUnitPrice: toFiniteNumberOrNull(fromDb.budgetAdoptedUnitPrice), consultCategoryFactor: toFiniteNumberOrNull(fromDb.consultCategoryFactor) } }) } const calcWorkloadBasicFee = (row: WorkloadRow) => { if ( row.budgetAdoptedUnitPrice == null || row.conversion == null || row.workload == null ) { return null } return roundTo( toDecimal(row.budgetAdoptedUnitPrice).mul(row.conversion).mul(row.workload), 2 ) } const calcWorkloadServiceFee = (row: WorkloadRow) => { if (row.consultCategoryFactor == null) { return null } const basicFee = row.basicFee ?? calcWorkloadBasicFee(row) if (basicFee == null) return null return roundTo( toDecimal(basicFee).mul(row.consultCategoryFactor), 2 ) } const getExpertEntries = () => Object.entries(expertList as Record).sort((a, b) => Number(a[0]) - Number(b[0])) const getDefaultHourlyAdoptedPrice = (expert: ExpertLite) => { if (expert.defPrice == null || expert.manageCoe == null) return null return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2) } const buildDefaultHourlyRows = (): HourlyRow[] => getExpertEntries().map(([expertId, expert]) => ({ id: `expert-${expertId}`, adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert), personnelCount: null, workdayCount: null })) const mergeHourlyRows = ( rowsFromDb: Array & Pick> | undefined ): HourlyRow[] => { const dbValueMap = toRowMap(rowsFromDb) return buildDefaultHourlyRows().map(row => { const fromDb = dbValueMap.get(row.id) if (!fromDb) return row return { ...row, adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice), personnelCount: toFiniteNumberOrNull(fromDb.personnelCount), workdayCount: toFiniteNumberOrNull(fromDb.workdayCount) } }) } const calcHourlyServiceBudget = (row: HourlyRow) => { if (row.adoptedBudgetUnitPrice == null || row.personnelCount == null || row.workdayCount == null) return null return roundTo(toDecimal(row.adoptedBudgetUnitPrice).mul(row.personnelCount).mul(row.workdayCount), 2) } const resolveScaleRows = ( serviceId: string, pricingData: StoredDetailRowsState | null, htData: StoredDetailRowsState | null, consultCategoryFactorMap?: Map, majorFactorMap?: Map ) => { if (pricingData?.detailRows != null) { return mergeScaleRows( serviceId, pricingData.detailRows as any, consultCategoryFactorMap, majorFactorMap ) } if (htData?.detailRows != null) { return mergeScaleRows( serviceId, stripGroupScaleRows(htData.detailRows as any), consultCategoryFactorMap, majorFactorMap ) } return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap) } const normalizeScopedScaleRows = ( serviceId: string, rowsFromDb: Array> | undefined, consultCategoryFactorMap?: Map, majorFactorMap?: Map ): ScaleRow[] => { const rows = stripGroupScaleRows(rowsFromDb) as Array> if (rows.length === 0) return [] const defaultConsultCategoryFactor = consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId) return rows.map(row => { const parsedMajorId = parseScopedMajorId(row.id) const resolvedMajorId = majorById.has(parsedMajorId) ? parsedMajorId : (majorIdAliasMap.get(parsedMajorId) || parsedMajorId) const hasConsultCategoryFactor = hasOwn(row, 'consultCategoryFactor') const hasMajorFactor = hasOwn(row, 'majorFactor') const hasWorkStageFactor = hasOwn(row, 'workStageFactor') const hasWorkRatio = hasOwn(row, 'workRatio') return { id: resolvedMajorId, amount: toFiniteNumberOrNull(row.amount), landArea: toFiniteNumberOrNull(row.landArea), benchmarkBudgetBasicChecked: typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true, benchmarkBudgetOptionalChecked: typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true, consultCategoryFactor: toFiniteNumberOrNull(row.consultCategoryFactor) ?? (hasConsultCategoryFactor ? null : defaultConsultCategoryFactor), majorFactor: toFiniteNumberOrNull(row.majorFactor) ?? (hasMajorFactor ? null : (majorFactorMap?.get(resolvedMajorId) ?? getDefaultMajorFactorById(resolvedMajorId))), workStageFactor: toFiniteNumberOrNull(row.workStageFactor) ?? (hasWorkStageFactor ? null : 1), workRatio: toFiniteNumberOrNull(row.workRatio) ?? (hasWorkRatio ? null : 100) } }) } // 统一生成某合同下某个咨询服务四种计费方式的存储键。 // 优先复用 Pinia store 当前约定的 key,避免与旧版 fallback key 脱节。 export const getPricingMethodDetailDbKeys = ( contractId: string, serviceId: string | number ): PricingMethodDetailDbKeys => { const normalizedServiceId = String(serviceId) const store = getZxFwStoreSafely() if (store) { return { investScale: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'investScale'), landScale: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'landScale'), workload: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'workload'), hourly: store.getServicePricingStorageKey(contractId, normalizedServiceId, 'hourly') } } return { investScale: `tzGMF-${contractId}-${normalizedServiceId}`, landScale: `ydGMF-${contractId}-${normalizedServiceId}`, workload: `gzlF-${contractId}-${normalizedServiceId}`, hourly: `hourlyPricing-${contractId}-${normalizedServiceId}` } } const loadPricingMethodDefaultBuildContext = async ( contractId: string, options?: PricingMethodTotalsOptions ): Promise => { const htDbKey = `ht-info-v3-${contractId}` const consultFactorDbKey = `ht-consult-category-factor-v1-${contractId}` const majorFactorDbKey = `ht-major-factor-v1-${contractId}` const baseInfoDbKey = 'xm-base-info-v1' const [htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([ kvGetItem(htDbKey), kvGetItem(consultFactorDbKey), kvGetItem(majorFactorDbKey), kvGetItem(baseInfoDbKey) ]) return { htData, consultCategoryFactorMap: buildConsultCategoryFactorMap(consultFactorData), majorFactorMap: buildMajorFactorMap(majorFactorData), industryId: typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '', excludeInvestmentCostAndAreaRows: options?.excludeInvestmentCostAndAreaRows === true } } const buildDefaultPricingMethodDetailRows = ( serviceId: string, context: PricingMethodDefaultBuildContext ): PricingMethodDefaultDetailRows => { const investScaleSingleTotal = usesInvestScaleSingleTotal(serviceId) const scaleRows = resolveScaleRows( serviceId, null, context.htData, context.consultCategoryFactorMap, context.majorFactorMap ) const investScale = investScaleSingleTotal ? buildInvestScaleSingleTotalDetailRows( serviceId, context.htData?.detailRows as Array> | undefined, context.consultCategoryFactorMap, context.majorFactorMap, context.industryId, context.htData?.totalAmount ?? null ) : scaleRows.filter(row => { if (!isCostMajorById(row.id)) return false if (context.excludeInvestmentCostAndAreaRows && isDualScaleMajorById(row.id)) return false return true }) const landScale = scaleRows.filter(row => isAreaMajorById(row.id)) return { investScale, landScale, workload: buildDefaultWorkloadRows(serviceId, context.consultCategoryFactorMap), hourly: buildDefaultHourlyRows() } } // 强制为一组服务重建并落库默认明细行。 // 这个方法会同时写入 Pinia 内存态和底层 KV 存储,适合“重置为默认值”场景。 export const persistDefaultPricingMethodDetailRowsForServices = async (params: { contractId: string serviceIds: Array options?: PricingMethodTotalsOptions }) => { const uniqueServiceIds = Array.from(new Set(params.serviceIds.map(serviceId => String(serviceId)))) if (uniqueServiceIds.length === 0) return const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options) const store = getZxFwStoreSafely() await Promise.all( uniqueServiceIds.map(async serviceId => { const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) const defaultRows = buildDefaultPricingMethodDetailRows(serviceId, context) if (store) { for (const method of SERVICE_PRICING_METHODS) { store.setServicePricingMethodState(params.contractId, serviceId, method, { detailRows: defaultRows[method] }, { force: true }) } } await Promise.all([ kvSetItem(dbKeys.investScale, { detailRows: defaultRows.investScale }), kvSetItem(dbKeys.landScale, { detailRows: defaultRows.landScale }), kvSetItem(dbKeys.workload, { detailRows: defaultRows.workload }), kvSetItem(dbKeys.hourly, { detailRows: defaultRows.hourly }) ]) }) ) } // 汇总单个服务的四类计费方式金额。 // 数据读取顺序是:优先读当前 Pinia 中已加载的计费页数据,缺失时再回退到 KV 存储和合同段默认信息。 export const getPricingMethodTotalsForService = async (params: { contractId: string serviceId: string | number options?: PricingMethodTotalsOptions }): Promise => { const serviceId = String(params.serviceId) const htDbKey = `ht-info-v3-${params.contractId}` const consultFactorDbKey = `ht-consult-category-factor-v1-${params.contractId}` const majorFactorDbKey = `ht-major-factor-v1-${params.contractId}` const baseInfoDbKey = 'xm-base-info-v1' const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) const store = getZxFwStoreSafely() const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'investScale') || Promise.resolve(null), store?.loadServicePricingMethodState>(params.contractId, serviceId, 'landScale') || Promise.resolve(null), store?.loadServicePricingMethodState>(params.contractId, serviceId, 'workload') || Promise.resolve(null), store?.loadServicePricingMethodState>(params.contractId, serviceId, 'hourly') || Promise.resolve(null), kvGetItem(htDbKey), kvGetItem(consultFactorDbKey), kvGetItem(majorFactorDbKey), kvGetItem(baseInfoDbKey) ]) const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([ storeInvestData ? Promise.resolve(null) : kvGetItem(dbKeys.investScale), storeLandData ? Promise.resolve(null) : kvGetItem(dbKeys.landScale), storeWorkloadData ? Promise.resolve(null) : kvGetItem(dbKeys.workload), storeHourlyData ? Promise.resolve(null) : kvGetItem(dbKeys.hourly) ]) const investData = toStoredDetailRowsState(storeInvestData) || investDataFallback const landData = toStoredDetailRowsState(storeLandData) || landDataFallback const workloadData = toStoredDetailRowsState(storeWorkloadData) || workloadDataFallback const hourlyData = toStoredDetailRowsState(storeHourlyData) || hourlyDataFallback const consultCategoryFactorMap = buildConsultCategoryFactorMap(consultFactorData) const majorFactorMap = buildMajorFactorMap(majorFactorData) const investScaleSingleTotal = usesInvestScaleSingleTotal(serviceId) const industryId = typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' // 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。 const excludeInvestmentCostAndAreaRows = params.options?.excludeInvestmentCostAndAreaRows === true const investScaleRowsSource = stripGroupScaleRows(investData?.detailRows as Array> | undefined) const landScaleRowsSource = stripGroupScaleRows(landData?.detailRows as Array> | undefined) const scopedInvestRows = hasScopedScaleRows(investScaleRowsSource) ? normalizeScopedScaleRows(serviceId, investScaleRowsSource, consultCategoryFactorMap, majorFactorMap) : null const scopedLandRows = hasScopedScaleRows(landScaleRowsSource) ? normalizeScopedScaleRows(serviceId, landScaleRowsSource, consultCategoryFactorMap, majorFactorMap) : null const investScale = investScaleSingleTotal ? getInvestScaleSingleTotalBudgetFee( serviceId, (investData?.detailRows as Array> | undefined) || (htData?.detailRows as Array> | undefined), consultCategoryFactorMap, majorFactorMap, industryId, htData?.totalAmount ?? null ) : (() => { const investRows = scopedInvestRows || resolveScaleRows( serviceId, investData, htData, consultCategoryFactorMap, majorFactorMap ) return sumByNumberNullable(investRows, row => { if (!isCostMajorById(row.id)) return null if (excludeInvestmentCostAndAreaRows && isDualScaleMajorById(row.id)) return null return getInvestmentBudgetFee(row) }) })() const landRows = scopedLandRows || resolveScaleRows( serviceId, landData, htData, consultCategoryFactorMap, majorFactorMap ) const landScale = sumByNumberNullable(landRows, row => (isAreaMajorById(row.id) ? getLandBudgetFee(row) : null)) const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap) const workload = defaultWorkloadRows.length === 0 ? null : sumByNumberNullable( workloadData?.detailRows != null ? mergeWorkloadRows(serviceId, workloadData.detailRows as any, consultCategoryFactorMap) : defaultWorkloadRows, row => calcWorkloadServiceFee(row) ) const hourlyRows = hourlyData?.detailRows != null ? mergeHourlyRows(hourlyData.detailRows as any) : buildDefaultHourlyRows() const hourly = sumByNumberNullable(hourlyRows, row => calcHourlyServiceBudget(row)) return { investScale, landScale, workload, hourly } } // 为一组服务补齐缺失的计费明细行,但不会覆盖已有用户数据。 // 适合在首次进入计费页或新增服务后做“按需初始化”。 export const ensurePricingMethodDetailRowsForServices = async (params: { contractId: string serviceIds: Array options?: PricingMethodTotalsOptions }) => { const uniqueServiceIds = Array.from(new Set(params.serviceIds.map(serviceId => String(serviceId)))) if (uniqueServiceIds.length === 0) return const context = await loadPricingMethodDefaultBuildContext(params.contractId, params.options) const store = getZxFwStoreSafely() await Promise.all( uniqueServiceIds.map(async serviceId => { const dbKeys = getPricingMethodDetailDbKeys(params.contractId, serviceId) const [storeInvestData, storeLandData, storeWorkloadData, storeHourlyData] = await Promise.all([ store?.loadServicePricingMethodState>(params.contractId, serviceId, 'investScale') || Promise.resolve(null), store?.loadServicePricingMethodState>(params.contractId, serviceId, 'landScale') || Promise.resolve(null), store?.loadServicePricingMethodState>(params.contractId, serviceId, 'workload') || Promise.resolve(null), store?.loadServicePricingMethodState>(params.contractId, serviceId, 'hourly') || Promise.resolve(null) ]) const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([ storeInvestData ? Promise.resolve(null) : kvGetItem(dbKeys.investScale), storeLandData ? Promise.resolve(null) : kvGetItem(dbKeys.landScale), storeWorkloadData ? Promise.resolve(null) : kvGetItem(dbKeys.workload), storeHourlyData ? Promise.resolve(null) : kvGetItem(dbKeys.hourly) ]) const investData = toStoredDetailRowsState(storeInvestData) || investDataFallback const landData = toStoredDetailRowsState(storeLandData) || landDataFallback const workloadData = toStoredDetailRowsState(storeWorkloadData) || workloadDataFallback const hourlyData = toStoredDetailRowsState(storeHourlyData) || hourlyDataFallback const shouldInitInvest = !Array.isArray(investData?.detailRows) || investData!.detailRows!.length === 0 const shouldInitLand = !Array.isArray(landData?.detailRows) || landData!.detailRows!.length === 0 const shouldInitWorkload = !Array.isArray(workloadData?.detailRows) || workloadData!.detailRows!.length === 0 const shouldInitHourly = !Array.isArray(hourlyData?.detailRows) || hourlyData!.detailRows!.length === 0 const writeTasks: Promise[] = [] let defaultRows: PricingMethodDefaultDetailRows | null = null const getDefaultRows = () => { if (!defaultRows) { defaultRows = buildDefaultPricingMethodDetailRows(serviceId, context) } return defaultRows } if (shouldInitInvest) { if (store) { store.setServicePricingMethodState(params.contractId, serviceId, 'investScale', { detailRows: getDefaultRows().investScale }, { force: true }) } writeTasks.push(kvSetItem(dbKeys.investScale, { detailRows: getDefaultRows().investScale })) } if (shouldInitLand) { if (store) { store.setServicePricingMethodState(params.contractId, serviceId, 'landScale', { detailRows: getDefaultRows().landScale }, { force: true }) } writeTasks.push(kvSetItem(dbKeys.landScale, { detailRows: getDefaultRows().landScale })) } if (shouldInitWorkload) { if (store) { store.setServicePricingMethodState(params.contractId, serviceId, 'workload', { detailRows: getDefaultRows().workload }, { force: true }) } writeTasks.push(kvSetItem(dbKeys.workload, { detailRows: getDefaultRows().workload })) } if (shouldInitHourly) { if (store) { store.setServicePricingMethodState(params.contractId, serviceId, 'hourly', { detailRows: getDefaultRows().hourly }, { force: true }) } writeTasks.push(kvSetItem(dbKeys.hourly, { detailRows: getDefaultRows().hourly })) } if (writeTasks.length > 0) { await Promise.all(writeTasks) } }) ) } // 并行汇总多个服务的计费结果,返回以 serviceId 为 key 的 Map。 export const getPricingMethodTotalsForServices = async (params: { contractId: string serviceIds: Array options?: PricingMethodTotalsOptions }) => { const result = new Map() await Promise.all( params.serviceIds.map(async serviceId => { const totals = await getPricingMethodTotalsForService({ contractId: params.contractId, serviceId, options: params.options }) result.set(String(serviceId), totals) }) ) return result }