import { expertList, getMajorDictEntries, getMajorIdAliasMap, getServiceDictById, taskList } from '@/sql' import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { toFiniteNumberOrNull } from '@/lib/number' import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee' import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing' import { useKvStore } from '@/pinia/kv' interface StoredDetailRowsState { detailRows?: T[] } 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 } interface ScaleRow { id: string amount: number | null landArea: number | null 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 onlyCostScale?: 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 getDefaultConsultCategoryFactor = (serviceId: string | number) => { const service = (getServiceDictById() as Record)[String(serviceId)] return toFiniteNumberOrNull(service?.defCoe) } const isOnlyCostScaleService = (serviceId: string | number) => { const service = (getServiceDictById() as Record)[String(serviceId)] return service?.onlyCostScale === true } 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 const budgetValue = toFiniteNumberOrNull(row.budgetValue) if (budgetValue != null) return budgetValue const standardFactor = toFiniteNumberOrNull(row.standardFactor) if (standardFactor != null) return 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, 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), 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 getBenchmarkBudgetByAmount = (amount: MaybeNumber) => getBenchmarkBudgetByScale(amount, 'cost') const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) => getBenchmarkBudgetByScale(landArea, 'area') const getInvestmentBudgetFee = (row: ScaleRow) => { return getScaleBudgetFee({ benchmarkBudget: getBenchmarkBudgetByAmount(row.amount), majorFactor: row.majorFactor, consultCategoryFactor: row.consultCategoryFactor, workStageFactor: row.workStageFactor, workRatio: row.workRatio }) } const getOnlyCostScaleBudgetFee = ( serviceId: string, rowsFromDb: Array> | undefined, consultCategoryFactorMap?: Map, majorFactorMap?: Map, industryId?: string | null ) => { const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) 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 // 新版 onlyCostScale 支持“按项目行”存储(如 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 getScaleBudgetFee({ benchmarkBudget: getBenchmarkBudgetByAmount(amount), majorFactor: getRowNumberOrFallback(row, 'majorFactor', defaultMajorFactor), consultCategoryFactor: getRowNumberOrFallback(row, 'consultCategoryFactor', defaultConsultCategoryFactor), workStageFactor: getRowNumberOrFallback(row, 'workStageFactor', 1), workRatio: getRowNumberOrFallback(row, 'workRatio', 100) }) }) } const totalAmount = sumByNumberNullable(sourceRows, row => typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null ) if (totalAmount == null) return null const onlyRow = sourceRows.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 getScaleBudgetFee({ benchmarkBudget: getBenchmarkBudgetByAmount(totalAmount), majorFactor, consultCategoryFactor, workStageFactor, workRatio }) } const buildOnlyCostScaleDetailRows = ( serviceId: string, rowsFromDb: Array> | undefined, consultCategoryFactorMap?: Map, majorFactorMap?: Map, industryId?: string | null ) => { const sourceRows = stripGroupScaleRows(rowsFromDb) const totalAmount = sumByNumberNullable(sourceRows, row => typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null ) const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId) const onlyCostRowId = industryMajorEntry?.id || ONLY_COST_SCALE_ROW_ID const onlyRow = sourceRows.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: totalAmount, consultCategoryFactor, majorFactor, workStageFactor, workRatio, benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true, benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true } ] } const getLandBudgetFee = (row: ScaleRow) => { return getScaleBudgetFee({ benchmarkBudget: getBenchmarkBudgetByLandArea(row.landArea), majorFactor: row.majorFactor, consultCategoryFactor: row.consultCategoryFactor, workStageFactor: row.workStageFactor, workRatio: row.workRatio }) } 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) } // 统一生成某合同下某个咨询服务四种计费方式的存储键。 // 优先复用 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 onlyCostScale = isOnlyCostScaleService(serviceId) const scaleRows = resolveScaleRows( serviceId, null, context.htData, context.consultCategoryFactorMap, context.majorFactorMap ) const investScale = onlyCostScale ? buildOnlyCostScaleDetailRows( serviceId, context.htData?.detailRows as Array> | undefined, context.consultCategoryFactorMap, context.majorFactorMap, context.industryId ) : 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 onlyCostScale = isOnlyCostScaleService(serviceId) const industryId = typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' // 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。 const excludeInvestmentCostAndAreaRows = params.options?.excludeInvestmentCostAndAreaRows === true const investScale = onlyCostScale ? getOnlyCostScaleBudgetFee( serviceId, (investData?.detailRows as Array> | undefined) || (htData?.detailRows as Array> | undefined), consultCategoryFactorMap, majorFactorMap, industryId ) : (() => { const investRows = 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 = 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 }