This commit is contained in:
zwgczx committed 2026-03-02 17:52:32 +08:00
1 parent 3950057707
commit a10359f7e0
19 files changed
+349 -299

No files matched your search

+8 -11
View File
@@ -1,4 +1,5 @@
import Decimal from 'decimal.js'
import { isFiniteNumber } from '@/lib/number'
type MaybeNumber = number | null | undefined
type DecimalInput = Decimal.Value
@@ -8,30 +9,26 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value)
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
export const addNumbers = (...values: MaybeNumber[]) => {
const sumFiniteValues = (values: Iterable<unknown>) => {
let total = new Decimal(0)
for (const value of values) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue
if (!isFiniteNumber(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const addNumbers = (...values: MaybeNumber[]) => sumFiniteValues(values)
export const sumByNumber = <T>(list: T[], pick: (item: T) => MaybeNumber) => {
let total = new Decimal(0)
for (const item of list) {
const value = pick(item)
if (typeof value !== 'number' || !Number.isFinite(value)) continue
if (!isFiniteNumber(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const decimalAggSum = (params: { values?: unknown[] }) => {
let total = new Decimal(0)
for (const value of params.values || []) {
if (typeof value !== 'number' || !Number.isFinite(value)) continue
total = total.plus(value)
}
return total.toNumber()
}
export const decimalAggSum = (params: { values?: unknown[] }) =>
sumFiniteValues(params.values || [])
+15 -24
View File
@@ -1,30 +1,25 @@
import { GridOptions, themeQuartz } from "ag-grid-community"
import type { GridOptions } from 'ag-grid-community'
import { themeQuartz } from 'ag-grid-community'
const borderConfig = {
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
width: 0.3, // 更细的边框,减少视觉干扰
color: "#d3d3d3" // 浅灰色边框,清新不刺眼
};
style: 'solid',
width: 0.3,
color: '#d3d3d3'
}
// 简洁清新风格的主题配置
export const myTheme = themeQuartz.withParams({
// 核心:移除外边框,减少视觉包裹感
wrapperBorder: false,
// 表头样式(柔和浅蓝,无加粗,更轻盈)
headerBackgroundColor: "#f0f2f3", // 极浅的背景色,替代深一点的 #e7f3fc
headerTextColor: "#374151", // 深灰色文字,比纯黑更柔和
headerFontSize: 15, // 字体稍大一点,更易读
headerFontWeight: "normal", // 取消加粗,降低视觉重量
// 行/列/表头边框(统一浅灰细边框)
headerBackgroundColor: '#f0f2f3',
headerTextColor: '#374151',
headerFontSize: 15,
headerFontWeight: 'normal',
rowBorder: borderConfig,
columnBorder: borderConfig,
headerRowBorder: borderConfig,
dataBackgroundColor: '#fefefe'
})
// 可选:偶数行背景色(轻微区分,更清新)
dataBackgroundColor: "#fefefe"
});
export const gridOptions: GridOptions<any> = {
export const gridOptions: GridOptions = {
treeData: true,
animateRows: true,
tooltipShowMode: 'whenTruncated',
@@ -32,13 +27,9 @@ export const gridOptions: GridOptions<any> = {
singleClickEdit: true,
suppressClickEdit: false,
suppressContextMenu: false,
// autoSizeStrategy: {
// type: 'fitGridWidth',
// defaultMinWidth: 100,
// },
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
// Keep group expand/collapse state when rowData updates after edits/saves.
// rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。
getRowId: params => String(params.data?.id ?? params.data?.path?.join('/') ?? ''),
getDataPath: data => data.path,
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
+20
View File
@@ -0,0 +1,20 @@
export const isFiniteNumber = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
export const toFiniteNumberOrNull = (value: unknown): number | null =>
isFiniteNumber(value) ? value : null
export const parseNumberOrNull = (
value: unknown,
options?: { sanitize?: boolean }
): number | null => {
if (value === '' || value == null) return null
const normalized =
options?.sanitize && typeof value === 'string'
? value.replace(/[^0-9.\-]/g, '')
: value
const numericValue = Number(normalized)
return Number.isFinite(numericValue) ? numericValue : null
}
+37 -14
View File
@@ -1,19 +1,42 @@
import { parseNumberOrNull } from '@/lib/number'
const fixedFormatterCache = new Map<number, Intl.NumberFormat>()
const flexibleFormatterCache = new Map<number, Intl.NumberFormat>()
const getFixedFormatter = (fractionDigits: number) => {
if (!fixedFormatterCache.has(fractionDigits)) {
fixedFormatterCache.set(
fractionDigits,
new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
})
)
}
return fixedFormatterCache.get(fractionDigits)!
}
const getFlexibleFormatter = (maxFractionDigits: number) => {
if (!flexibleFormatterCache.has(maxFractionDigits)) {
flexibleFormatterCache.set(
maxFractionDigits,
new Intl.NumberFormat('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits
})
)
}
return flexibleFormatterCache.get(maxFractionDigits)!
}
export const formatThousands = (value: unknown, fractionDigits = 2) => {
if (value === '' || value == null) return ''
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return ''
return numericValue.toLocaleString('zh-CN', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits
})
const numericValue = parseNumberOrNull(value)
if (numericValue == null) return ''
return getFixedFormatter(fractionDigits).format(numericValue)
}
export const formatThousandsFlexible = (value: unknown, maxFractionDigits = 20) => {
if (value === '' || value == null) return ''
const numericValue = Number(value)
if (!Number.isFinite(numericValue)) return ''
return numericValue.toLocaleString('zh-CN', {
minimumFractionDigits: 0,
maximumFractionDigits: maxFractionDigits
})
const numericValue = parseNumberOrNull(value)
if (numericValue == null) return ''
return getFlexibleFormatter(maxFractionDigits).format(numericValue)
}
+46 -43
View File
@@ -1,6 +1,8 @@
import localforage from 'localforage'
import { expertList, getBasicFeeFromScale, majorList, serviceList, taskList } from '@/sql'
import { addNumbers, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { expertList, majorList, serviceList, taskList } from '@/sql'
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee'
interface StoredDetailRowsState<T = any> {
detailRows?: T[]
@@ -58,12 +60,17 @@ export interface PricingMethodTotals {
hourly: number | null
}
const toFiniteNumberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null
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
}
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
const service = (serviceList as Record<string, ServiceLite | undefined>)[String(serviceId)]
return toFiniteNumberOrNull(service?.defCoe)
@@ -95,10 +102,7 @@ const mergeScaleRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
): ScaleRow[] => {
const dbValueMap = new Map<string, Partial<ScaleRow> & Pick<ScaleRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
const dbValueMap = toRowMap(rowsFromDb)
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
return buildDefaultScaleRows(serviceId).map(row => {
@@ -122,26 +126,26 @@ const mergeScaleRows = (
})
}
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) => {
const result = getBasicFeeFromScale(amount, 'cost')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) =>
getBenchmarkBudgetByScale(amount, 'cost')
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) => {
const result = getBasicFeeFromScale(landArea, 'area')
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) =>
getBenchmarkBudgetByScale(landArea, 'area')
const getInvestmentBudgetFee = (row: ScaleRow) => {
const benchmarkBudget = getBenchmarkBudgetByAmount(row.amount)
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(row.amount),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor
})
}
const getLandBudgetFee = (row: ScaleRow) => {
const benchmarkBudget = getBenchmarkBudgetByLandArea(row.landArea)
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByLandArea(row.landArea),
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor
})
}
const getTaskEntriesByServiceId = (serviceId: string | number) =>
@@ -164,10 +168,7 @@ const mergeWorkloadRows = (
serviceId: string | number,
rowsFromDb: Array<Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>> | undefined
): WorkloadRow[] => {
const dbValueMap = new Map<string, Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
const dbValueMap = toRowMap(rowsFromDb)
return buildDefaultWorkloadRows(serviceId).map(row => {
const fromDb = dbValueMap.get(row.id)
@@ -216,10 +217,7 @@ const buildDefaultHourlyRows = (): HourlyRow[] =>
const mergeHourlyRows = (
rowsFromDb: Array<Partial<HourlyRow> & Pick<HourlyRow, 'id'>> | undefined
): HourlyRow[] => {
const dbValueMap = new Map<string, Partial<HourlyRow> & Pick<HourlyRow, 'id'>>()
for (const row of rowsFromDb || []) {
dbValueMap.set(String(row.id), row)
}
const dbValueMap = toRowMap(rowsFromDb)
return buildDefaultHourlyRows().map(row => {
const fromDb = dbValueMap.get(row.id)
@@ -239,6 +237,20 @@ const calcHourlyServiceBudget = (row: HourlyRow) => {
return roundTo(toDecimal(row.adoptedBudgetUnitPrice).mul(row.personnelCount).mul(row.workdayCount), 2)
}
const resolveScaleRows = (
serviceId: string,
pricingData: StoredDetailRowsState | null,
htData: StoredDetailRowsState | null
) => {
if (pricingData?.detailRows != null) {
return mergeScaleRows(serviceId, pricingData.detailRows as any)
}
if (htData?.detailRows != null) {
return mergeScaleRows(serviceId, htData.detailRows as any)
}
return buildDefaultScaleRows(serviceId)
}
export const getPricingMethodTotalsForService = async (params: {
contractId: string
serviceId: string | number
@@ -258,20 +270,11 @@ export const getPricingMethodTotalsForService = async (params: {
localforage.getItem<StoredDetailRowsState>(htDbKey)
])
const investRows =
investData?.detailRows != null
? mergeScaleRows(serviceId, investData.detailRows as any)
: htData?.detailRows != null
? mergeScaleRows(serviceId, htData.detailRows as any)
: buildDefaultScaleRows(serviceId)
// 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。
const investRows = resolveScaleRows(serviceId, investData, htData)
const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row))
const landRows =
landData?.detailRows != null
? mergeScaleRows(serviceId, landData.detailRows as any)
: htData?.detailRows != null
? mergeScaleRows(serviceId, htData.detailRows as any)
: buildDefaultScaleRows(serviceId)
const landRows = resolveScaleRows(serviceId, landData, htData)
const landScale = sumByNumber(landRows, row => getLandBudgetFee(row))
const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId)
+27
View File
@@ -0,0 +1,27 @@
import { getBasicFeeFromScale } from '@/sql'
import { addNumbers, roundTo, toDecimal } from '@/lib/decimal'
import { toFiniteNumberOrNull } from '@/lib/number'
type ScaleMode = 'cost' | 'area'
export const getBenchmarkBudgetByScale = (value: unknown, mode: ScaleMode) => {
const scaleValue = toFiniteNumberOrNull(value)
const result = getBasicFeeFromScale(scaleValue, mode)
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
}
export const getScaleBudgetFee = (params: {
benchmarkBudget: unknown
majorFactor: unknown
consultCategoryFactor: unknown
}) => {
const benchmarkBudget = toFiniteNumberOrNull(params.benchmarkBudget)
const majorFactor = toFiniteNumberOrNull(params.majorFactor)
const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor)
if (benchmarkBudget == null || majorFactor == null || consultCategoryFactor == null) {
return null
}
return roundTo(toDecimal(benchmarkBudget).mul(majorFactor).mul(consultCategoryFactor), 2)
}
+1 -3
View File
@@ -1,5 +1,6 @@
import localforage from 'localforage'
import { majorList, serviceList } from '@/sql'
import { toFiniteNumberOrNull } from '@/lib/number'
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
@@ -20,9 +21,6 @@ type FactorDictItem = {
type FactorDict = Record<string, FactorDictItem>
const toFiniteNumberOrNull = (value: unknown): number | null =>
typeof value === 'number' && Number.isFinite(value) ? value : null
const buildStandardFactorMap = (dict: FactorDict): Map<string, number | null> => {
const map = new Map<string, number | null>()
for (const [id, item] of Object.entries(dict)) {
+1 -3
View File
@@ -1,4 +1,5 @@
import localforage from 'localforage'
import { toFiniteNumberOrNull } from '@/lib/number'
export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly'
@@ -18,9 +19,6 @@ interface ZxFwState {
export const ZXFW_RELOAD_SERVICE_KEY = 'zxfw-main'
const toFiniteNumberOrNull = (value: number | null | undefined) =>
typeof value === 'number' && Number.isFinite(value) ? value : null
export const syncPricingTotalToZxFw = async (params: {
contractId: string
serviceId: string | number