优化
This commit is contained in:
1 parent
3950057707
commit
a10359f7e0
19 files changed
+349
-299
No files matched your search
+8
-11
@@ -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
@@ -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'],
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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,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
|
||||
|
||||
Reference in new issue
Block a user