931 lines
33 KiB
TypeScript
931 lines
33 KiB
TypeScript
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<T = any> {
|
||
detailRows?: T[]
|
||
}
|
||
|
||
interface StoredFactorState {
|
||
detailRows?: Array<{
|
||
id: string
|
||
standardFactor?: number | null
|
||
budgetValue?: number | null
|
||
}>
|
||
}
|
||
|
||
type MaybeNumber = number | null | undefined
|
||
|
||
const sumByNumberNullable = <T>(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<string, number | null>
|
||
majorFactorMap: Map<string, number | null>
|
||
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 <T = unknown>(key: string): Promise<T | null> => {
|
||
const store = getKvStoreSafely()
|
||
if (!store) return null
|
||
return store.getItem<T>(key)
|
||
}
|
||
|
||
const kvSetItem = async <T = unknown>(key: string, value: T): Promise<void> => {
|
||
const store = getKvStoreSafely()
|
||
if (!store) return
|
||
await store.setItem(key, value)
|
||
}
|
||
|
||
const toStoredDetailRowsState = <TRow = unknown>(state: { detailRows?: TRow[] } | null | undefined): StoredDetailRowsState<TRow> | 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 getRowNumberOrFallback = (
|
||
row: Record<string, unknown> | 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 = <TRow extends { id: string }>(rows?: TRow[]) => {
|
||
const map = new Map<string, TRow>()
|
||
for (const row of rows || []) {
|
||
map.set(String(row.id), row)
|
||
}
|
||
return map
|
||
}
|
||
|
||
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
|
||
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||
return toFiniteNumberOrNull(service?.defCoe)
|
||
}
|
||
|
||
const isOnlyCostScaleService = (serviceId: string | number) => {
|
||
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[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<string, number | null>()
|
||
const serviceDict = getServiceDictById() as Record<string, ServiceLite | undefined>
|
||
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<string, number | null>()
|
||
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<string, number | null>,
|
||
majorFactorMap?: Map<string, number | null>
|
||
): 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<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined,
|
||
consultCategoryFactorMap?: Map<string, number | null>,
|
||
majorFactorMap?: Map<string, number | null>
|
||
): ScaleRow[] => {
|
||
const dbValueMap = toRowMap(rowsFromDb)
|
||
for (const row of rowsFromDb || []) {
|
||
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<ScaleRow>).workStageFactor) ??
|
||
(hasWorkStageFactor ? null : row.workStageFactor),
|
||
workRatio:
|
||
toFiniteNumberOrNull((fromDb as Partial<ScaleRow>).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<Record<string, unknown>> | undefined,
|
||
consultCategoryFactorMap?: Map<string, number | null>,
|
||
majorFactorMap?: Map<string, number | null>,
|
||
industryId?: string | null
|
||
) => {
|
||
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
|
||
const sourceRows = 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<Record<string, unknown>> | undefined,
|
||
consultCategoryFactorMap?: Map<string, number | null>,
|
||
majorFactorMap?: Map<string, number | null>,
|
||
industryId?: string | null
|
||
) => {
|
||
const totalAmount = sumByNumberNullable(rowsFromDb || [], 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 =
|
||
(rowsFromDb || []).find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
|
||
(rowsFromDb || []).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<string, TaskLite>)
|
||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||
.filter(([, task]) => Number(task.serviceID) === Number(serviceId))
|
||
|
||
const buildDefaultWorkloadRows = (
|
||
serviceId: string | number,
|
||
consultCategoryFactorMap?: Map<string, number | null>
|
||
): 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<Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>> | undefined,
|
||
consultCategoryFactorMap?: Map<string, number | null>
|
||
): 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<string, ExpertLite>).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<Partial<HourlyRow> & Pick<HourlyRow, 'id'>> | 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<string, number | null>,
|
||
majorFactorMap?: Map<string, number | null>
|
||
) => {
|
||
if (pricingData?.detailRows != null) {
|
||
return mergeScaleRows(
|
||
serviceId,
|
||
pricingData.detailRows as any,
|
||
consultCategoryFactorMap,
|
||
majorFactorMap
|
||
)
|
||
}
|
||
if (htData?.detailRows != null) {
|
||
return mergeScaleRows(
|
||
serviceId,
|
||
htData.detailRows as any,
|
||
consultCategoryFactorMap,
|
||
majorFactorMap
|
||
)
|
||
}
|
||
return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap)
|
||
}
|
||
|
||
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<PricingMethodDefaultBuildContext> => {
|
||
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<StoredDetailRowsState>(htDbKey),
|
||
kvGetItem<StoredFactorState>(consultFactorDbKey),
|
||
kvGetItem<StoredFactorState>(majorFactorDbKey),
|
||
kvGetItem<XmBaseInfoState>(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<Record<string, unknown>> | 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()
|
||
}
|
||
}
|
||
|
||
export const persistDefaultPricingMethodDetailRowsForServices = async (params: {
|
||
contractId: string
|
||
serviceIds: Array<string | number>
|
||
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 })
|
||
])
|
||
})
|
||
)
|
||
}
|
||
|
||
export const getPricingMethodTotalsForService = async (params: {
|
||
contractId: string
|
||
serviceId: string | number
|
||
options?: PricingMethodTotalsOptions
|
||
}): Promise<PricingMethodTotals> => {
|
||
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<Record<string, unknown>>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
|
||
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
|
||
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
|
||
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'hourly') || Promise.resolve(null),
|
||
kvGetItem<StoredDetailRowsState>(htDbKey),
|
||
kvGetItem<StoredFactorState>(consultFactorDbKey),
|
||
kvGetItem<StoredFactorState>(majorFactorDbKey),
|
||
kvGetItem<XmBaseInfoState>(baseInfoDbKey)
|
||
])
|
||
|
||
const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([
|
||
storeInvestData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(dbKeys.investScale),
|
||
storeLandData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(dbKeys.landScale),
|
||
storeWorkloadData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(dbKeys.workload),
|
||
storeHourlyData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(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<Record<string, unknown>> | undefined) ||
|
||
(htData?.detailRows as Array<Record<string, unknown>> | 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<string | number>
|
||
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<Record<string, unknown>>(params.contractId, serviceId, 'investScale') || Promise.resolve(null),
|
||
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'landScale') || Promise.resolve(null),
|
||
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'workload') || Promise.resolve(null),
|
||
store?.loadServicePricingMethodState<Record<string, unknown>>(params.contractId, serviceId, 'hourly') || Promise.resolve(null)
|
||
])
|
||
const [investDataFallback, landDataFallback, workloadDataFallback, hourlyDataFallback] = await Promise.all([
|
||
storeInvestData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(dbKeys.investScale),
|
||
storeLandData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(dbKeys.landScale),
|
||
storeWorkloadData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(dbKeys.workload),
|
||
storeHourlyData ? Promise.resolve(null) : kvGetItem<StoredDetailRowsState>(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<unknown>[] = []
|
||
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)
|
||
}
|
||
})
|
||
)
|
||
}
|
||
|
||
export const getPricingMethodTotalsForServices = async (params: {
|
||
contractId: string
|
||
serviceIds: Array<string | number>
|
||
options?: PricingMethodTotalsOptions
|
||
}) => {
|
||
const result = new Map<string, PricingMethodTotals>()
|
||
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
|
||
}
|