1
This commit is contained in:
1 parent
66069ef0f1
commit
4f46b23769
9 files changed
+1271
-579
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in new issue
Block a user