This commit is contained in:
zwgczx committed 2026-03-18 18:46:58 +08:00
1 parent 66069ef0f1
commit 4f46b23769
9 files changed
+1271 -579

No files matched your search

+69
View File
@@ -0,0 +1,69 @@
/**
* 工时法计算模块
*
* 提供工时法的行构建、合并、费用计算等纯函数,
* 供 HourlyPricingPane/HourlyFeeGrid 和 pricingMethodTotals.ts 共用。
*/
import { expertList } from '@/sql'
import { roundTo, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number'
import type { HourlyDetailRow, ExpertLite } from '@/types/pricing'
/* ----------------------------------------------------------------
* 专家字典查询
* ---------------------------------------------------------------- */
/** 获取专家条目列表(按 ID 排序) */
export const getExpertEntries = (): [string, ExpertLite][] =>
Object.entries(expertList as Record<string, ExpertLite>)
.sort((a, b) => Number(a[0]) - Number(b[0]))
/** 计算专家默认采用单价 = 基准单价 × 管理系数 */
const getDefaultHourlyAdoptedPrice = (expert: ExpertLite): number | null => {
if (expert.defPrice == null || expert.manageCoe == null) return null
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
}
/* ----------------------------------------------------------------
* 行构建与合并
* ---------------------------------------------------------------- */
/** 构建工时法默认行 */
export const buildDefaultHourlyRows = (): HourlyDetailRow[] =>
getExpertEntries().map(([expertId, expert]) => ({
id: `expert-${expertId}`,
adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert),
personnelCount: null,
workdayCount: null
}))
/** 合并持久化行与默认行 */
export const mergeHourlyRows = (
rowsFromDb: Array<Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>> | undefined
): HourlyDetailRow[] => {
const dbMap = new Map<string, Partial<HourlyDetailRow> & Pick<HourlyDetailRow, 'id'>>()
for (const row of rowsFromDb || []) dbMap.set(row.id, row)
return buildDefaultHourlyRows().map(row => {
const fromDb = dbMap.get(row.id)
if (!fromDb) return row
return {
...row,
adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice),
personnelCount: toFiniteNumberOrNull(fromDb.personnelCount),
workdayCount: toFiniteNumberOrNull(fromDb.workdayCount)
}
})
}
/* ----------------------------------------------------------------
* 费用计算
* ---------------------------------------------------------------- */
/** 计算工时法单行费用 = 采用单价 × 人数 × 工日数 */
export const calcHourlyServiceBudget = (row: HourlyDetailRow): number | null => {
const { adoptedBudgetUnitPrice, personnelCount, workdayCount } = row
if (adoptedBudgetUnitPrice == null || personnelCount == null || workdayCount == null) return null
return roundTo(toDecimal(adoptedBudgetUnitPrice).mul(personnelCount).mul(workdayCount), 2)
}
+70
View File
@@ -0,0 +1,70 @@
/**
* 计价法持久化控制
*
* 管理 sessionStorage 中的 skip/force 标记,
* 用于控制计价法组件在清除/重建默认数据时的竞态。
*/
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
/**
* 判断当前是否应跳过持久化写入
* 用于防止组件卸载时覆盖刚被清除的数据
*/
export const shouldSkipPersist = (dbKey: string, paneCreatedAt: number): boolean => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
/**
* 判断当前是否应强制加载默认数据(忽略已有持久化数据)
* 读取后立即清除标记(一次性)
*/
export const shouldForceDefaultLoad = (dbKey: string): boolean => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
/**
* 设置跳过持久化标记
* @param dbKey 存储键
* @param durationMs 有效时长(毫秒),默认 3000ms
*/
export const markSkipPersist = (dbKey: string, durationMs = 3000): void => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${dbKey}`
const now = Date.now()
sessionStorage.setItem(storageKey, `${now}:${now + durationMs}`)
}
/**
* 设置强制加载默认数据标记
* @param dbKey 存储键
* @param durationMs 有效时长(毫秒),默认 3000ms
*/
export const markForceDefaultLoad = (dbKey: string, durationMs = 3000): void => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${dbKey}`
sessionStorage.setItem(storageKey, String(Date.now() + durationMs))
}
+218
View File
@@ -0,0 +1,218 @@
/**
* 规模法通用计算(投资规模法 + 用地规模法共用)
*
* 提供行默认值构建、行合并、费用计算等纯函数,
* 供 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
}
+99
View File
@@ -0,0 +1,99 @@
/**
* 工作量法计算模块
*
* 提供工作量法的行构建、合并、费用计算等纯函数,
* 供 WorkloadPricingPane.vue 和 pricingMethodTotals.ts 共用。
*/
import { taskList } from '@/sql'
import { roundTo, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number'
import { getDefaultConsultCategoryFactor } from '@/lib/pricingScaleCalc'
import type { WorkloadCalcRow, TaskLite } from '@/types/pricing'
/* ----------------------------------------------------------------
* 任务字典查询
* ---------------------------------------------------------------- */
/** 获取指定咨询服务下的任务条目(按 ID 排序) */
export const getTaskEntriesByServiceId = (serviceId: string | number): [string, TaskLite][] =>
Object.entries(taskList as Record<string, TaskLite>)
.filter(([, task]) => Number(task.serviceID) === Number(serviceId))
.sort((a, b) => Number(a[0]) - Number(b[0]))
/** 判断指定服务是否有工作量法任务 */
export const hasWorkloadTasks = (serviceId: string | number): boolean =>
getTaskEntriesByServiceId(serviceId).length > 0
/** 格式化任务参考单价范围文本 */
export const formatTaskReferenceUnitPrice = (task: TaskLite): string => {
const unit = task.unit || ''
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
const hasMax = typeof task.maxPrice === 'number' && Number.isFinite(task.maxPrice)
if (hasMin && hasMax) return `${task.minPrice}${unit}-${task.maxPrice}${unit}`
if (hasMin) return `${task.minPrice}${unit}`
if (hasMax) return `${task.maxPrice}${unit}`
return ''
}
/* ----------------------------------------------------------------
* 行构建与合并
* ---------------------------------------------------------------- */
/** 构建工作量法默认行 */
export const buildDefaultWorkloadRows = (
serviceId: string | number,
consultCategoryFactorMap?: Map<string, number | null>
): WorkloadCalcRow[] => {
const defaultFactor =
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: defaultFactor
}))
}
/** 合并持久化行与默认行 */
export const mergeWorkloadRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<WorkloadCalcRow> & Pick<WorkloadCalcRow, 'id'>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>
): WorkloadCalcRow[] => {
const dbMap = new Map<string, Partial<WorkloadCalcRow> & Pick<WorkloadCalcRow, 'id'>>()
for (const row of rowsFromDb || []) dbMap.set(row.id, row)
return buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap).map(row => {
const fromDb = dbMap.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)
}
})
}
/* ----------------------------------------------------------------
* 费用计算
* ---------------------------------------------------------------- */
/** 计算工作量法基本费用 = 采用单价 × 换算系数 × 工作量 */
export const calcWorkloadBasicFee = (row: WorkloadCalcRow): number | null => {
const { budgetAdoptedUnitPrice, conversion, workload } = row
if (budgetAdoptedUnitPrice == null || conversion == null || workload == null) return null
return roundTo(toDecimal(budgetAdoptedUnitPrice).mul(conversion).mul(workload), 2)
}
/** 计算工作量法服务费用 = 基本费用 × 咨询分类系数 */
export const calcWorkloadServiceFee = (row: WorkloadCalcRow): number | null => {
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)
}