219 lines
8.1 KiB
TypeScript
219 lines
8.1 KiB
TypeScript
/**
|
||
* 规模法通用计算(投资规模法 + 用地规模法共用)
|
||
*
|
||
* 提供行默认值构建、行合并、费用计算等纯函数,
|
||
* 供 ScalePricingPane.vue 和 pricingMethodTotals.ts 共用。
|
||
*/
|
||
|
||
import { getMajorDictEntries, getMajorIdAliasMap, getServiceDictById } from '@/sql'
|
||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||
import type {
|
||
ScaleCalcRow,
|
||
ScaleType,
|
||
MajorLite,
|
||
ServiceLite
|
||
} from '@/types/pricing'
|
||
|
||
/* ----------------------------------------------------------------
|
||
* 专业字典查询
|
||
* ---------------------------------------------------------------- */
|
||
|
||
const majorById = new Map(
|
||
getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite])
|
||
)
|
||
const majorIdAliasMap = getMajorIdAliasMap()
|
||
|
||
/** 获取专业叶子节点 ID 列表(code 含 '-' 的为叶子) */
|
||
export const getMajorLeafIds = (): string[] =>
|
||
getMajorDictEntries()
|
||
.filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
|
||
.map(({ id }) => id)
|
||
|
||
/** 解析专业 ID 别名 */
|
||
export const resolveMajorId = (id: string): string =>
|
||
majorById.has(id) ? id : majorIdAliasMap.get(id) || id
|
||
|
||
/** 获取专业默认系数 */
|
||
export const getDefaultMajorFactor = (id: string): number | null => {
|
||
const resolvedId = resolveMajorId(id)
|
||
return toFiniteNumberOrNull(majorById.get(resolvedId)?.defCoe)
|
||
}
|
||
|
||
/** 判断专业是否支持投资规模(hasCost) */
|
||
export const isCostMajor = (id: string): boolean => {
|
||
const resolvedId = resolveMajorId(id)
|
||
return majorById.get(resolvedId)?.hasCost !== false
|
||
}
|
||
|
||
/** 判断专业是否支持用地规模(hasArea) */
|
||
export const isAreaMajor = (id: string): boolean => {
|
||
const resolvedId = resolveMajorId(id)
|
||
return majorById.get(resolvedId)?.hasArea !== false
|
||
}
|
||
|
||
/** 判断专业是否同时支持投资和用地 */
|
||
export const isDualScaleMajor = (id: string): boolean =>
|
||
isCostMajor(id) && isAreaMajor(id)
|
||
|
||
/** 根据行业 ID 查找对应的专业条目 */
|
||
export const getIndustryMajorEntry = (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
|
||
}
|
||
|
||
/* ----------------------------------------------------------------
|
||
* 咨询服务字典查询
|
||
* ---------------------------------------------------------------- */
|
||
|
||
/** 获取咨询服务默认分类系数 */
|
||
export const getDefaultConsultCategoryFactor = (serviceId: string | number): number | null => {
|
||
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||
return toFiniteNumberOrNull(service?.defCoe)
|
||
}
|
||
|
||
/** 判断是否为仅投资规模服务 */
|
||
export const isOnlyCostScaleService = (serviceId: string | number): boolean => {
|
||
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||
return service?.onlyCostScale === true
|
||
}
|
||
|
||
/* ----------------------------------------------------------------
|
||
* 行构建与合并
|
||
* ---------------------------------------------------------------- */
|
||
|
||
/** 判断是否为分组汇总行(AG Grid tree 用) */
|
||
export const isGroupScaleRow = (row: unknown): boolean =>
|
||
Boolean(row && typeof row === 'object' && (row as Record<string, unknown>).isGroupRow === true)
|
||
|
||
/** 过滤掉分组汇总行 */
|
||
export const stripGroupScaleRows = <TRow>(rows: TRow[] | undefined): TRow[] =>
|
||
(rows || []).filter(row => !isGroupScaleRow(row))
|
||
|
||
/** 构建规模法默认行(全部专业叶子) */
|
||
export const buildDefaultScaleRows = (
|
||
serviceId: string | number,
|
||
consultCategoryFactorMap?: Map<string, number | null>,
|
||
majorFactorMap?: Map<string, number | null>
|
||
): ScaleCalcRow[] => {
|
||
const defaultFactor =
|
||
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
|
||
return getMajorLeafIds().map(id => ({
|
||
id,
|
||
amount: null,
|
||
landArea: null,
|
||
consultCategoryFactor: defaultFactor,
|
||
majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactor(id),
|
||
workStageFactor: 1,
|
||
workRatio: 100
|
||
}))
|
||
}
|
||
|
||
const hasOwn = (obj: unknown, key: string) =>
|
||
Object.prototype.hasOwnProperty.call(obj || {}, key)
|
||
|
||
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
|
||
}
|
||
|
||
/** 合并持久化行与默认行(保留用户编辑值,补全缺失字段) */
|
||
export const mergeScaleRows = (
|
||
serviceId: string | number,
|
||
rowsFromDb: Array<Partial<ScaleCalcRow> & Pick<ScaleCalcRow, 'id'>> | undefined,
|
||
consultCategoryFactorMap?: Map<string, number | null>,
|
||
majorFactorMap?: Map<string, number | null>
|
||
): ScaleCalcRow[] => {
|
||
const sourceRows = stripGroupScaleRows(rowsFromDb)
|
||
const dbValueMap = toRowMap(sourceRows)
|
||
// 处理 ID 别名映射
|
||
for (const row of sourceRows) {
|
||
const nextId = majorIdAliasMap.get(String(row.id))
|
||
if (nextId && !dbValueMap.has(nextId)) {
|
||
dbValueMap.set(nextId, row as ScaleCalcRow)
|
||
}
|
||
}
|
||
|
||
const defaultFactor =
|
||
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
|
||
|
||
return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap).map(row => {
|
||
const fromDb = dbValueMap.get(row.id)
|
||
if (!fromDb) return row
|
||
return {
|
||
...row,
|
||
amount: toFiniteNumberOrNull(fromDb.amount),
|
||
landArea: toFiniteNumberOrNull(fromDb.landArea),
|
||
consultCategoryFactor:
|
||
toFiniteNumberOrNull(fromDb.consultCategoryFactor) ??
|
||
(hasOwn(fromDb, 'consultCategoryFactor') ? null : defaultFactor),
|
||
majorFactor:
|
||
toFiniteNumberOrNull(fromDb.majorFactor) ??
|
||
(hasOwn(fromDb, 'majorFactor') ? null : (majorFactorMap?.get(row.id) ?? getDefaultMajorFactor(row.id))),
|
||
workStageFactor:
|
||
toFiniteNumberOrNull(fromDb.workStageFactor) ??
|
||
(hasOwn(fromDb, 'workStageFactor') ? null : row.workStageFactor),
|
||
workRatio:
|
||
toFiniteNumberOrNull(fromDb.workRatio) ??
|
||
(hasOwn(fromDb, 'workRatio') ? null : row.workRatio)
|
||
}
|
||
})
|
||
}
|
||
|
||
/* ----------------------------------------------------------------
|
||
* 费用计算
|
||
* ---------------------------------------------------------------- */
|
||
|
||
/** 计算投资规模法单行费用 */
|
||
export const calcInvestBudgetFee = (row: ScaleCalcRow): number | null =>
|
||
getScaleBudgetFee({
|
||
benchmarkBudget: getBenchmarkBudgetByScale(row.amount, 'cost'),
|
||
majorFactor: row.majorFactor,
|
||
consultCategoryFactor: row.consultCategoryFactor,
|
||
workStageFactor: row.workStageFactor,
|
||
workRatio: row.workRatio
|
||
})
|
||
|
||
/** 计算用地规模法单行费用 */
|
||
export const calcLandBudgetFee = (row: ScaleCalcRow): number | null =>
|
||
getScaleBudgetFee({
|
||
benchmarkBudget: getBenchmarkBudgetByScale(row.landArea, 'area'),
|
||
majorFactor: row.majorFactor,
|
||
consultCategoryFactor: row.consultCategoryFactor,
|
||
workStageFactor: row.workStageFactor,
|
||
workRatio: row.workRatio
|
||
})
|
||
|
||
/** 根据规模类型计算单行费用 */
|
||
export const calcScaleBudgetFee = (row: ScaleCalcRow, scaleType: ScaleType): number | null =>
|
||
scaleType === 'invest' ? calcInvestBudgetFee(row) : calcLandBudgetFee(row)
|
||
|
||
/** 判断行是否属于指定规模类型 */
|
||
export const isRowForScaleType = (rowId: string, scaleType: ScaleType): boolean =>
|
||
scaleType === 'invest' ? isCostMajor(rowId) : isAreaMajor(rowId)
|
||
|
||
/* ----------------------------------------------------------------
|
||
* 可空数值求和
|
||
* ---------------------------------------------------------------- */
|
||
|
||
/** 对数组求和,全部为 null 时返回 null */
|
||
export const sumNullableBy = <T>(list: T[], pick: (item: T) => number | null | undefined): number | null => {
|
||
let hasValid = false
|
||
let total = 0
|
||
for (const item of list) {
|
||
const value = toFiniteNumberOrNull(pick(item))
|
||
if (value == null) continue
|
||
hasValid = true
|
||
total += value
|
||
}
|
||
return hasValid ? total : null
|
||
}
|