From a10359f7e0dc98ac17de489c5ddff2a4ab93dbd1 Mon Sep 17 00:00:00 2001 From: wintsa <770775984@qq.com> Date: Mon, 2 Mar 2026 17:52:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.vue | 6 +- src/components/views/XmFactorGrid.vue | 9 +- src/components/views/htInfo.vue | 2 +- .../views/pricingView/HourlyPricingPane.vue | 9 +- .../InvestmentScalePricingPane.vue | 26 +-- .../pricingView/LandScalePricingPane.vue | 26 +-- .../views/pricingView/WorkloadPricingPane.vue | 17 +- src/components/views/xmInfo.vue | 2 +- src/lib/decimal.ts | 19 +- src/lib/diyAgGridOptions.ts | 39 ++-- src/lib/number.ts | 20 ++ src/lib/numberFormat.ts | 51 +++-- src/lib/pricingMethodTotals.ts | 89 ++++---- src/lib/pricingScaleFee.ts | 27 +++ src/lib/xmFactorDefaults.ts | 4 +- src/lib/zxFwPricingSync.ts | 4 +- src/main.ts | 45 ++-- src/pinia/tab.ts | 199 +++++++++--------- src/sql.ts | 54 ++--- 19 files changed, 349 insertions(+), 299 deletions(-) create mode 100644 src/lib/number.ts create mode 100644 src/lib/pricingScaleFee.ts diff --git a/src/App.vue b/src/App.vue index b4a3562..2bca678 100644 --- a/src/App.vue +++ b/src/App.vue @@ -3,9 +3,5 @@ import Tab from '@/layout/tab.vue' - - diff --git a/src/components/views/XmFactorGrid.vue b/src/components/views/XmFactorGrid.vue index 8b41c3f..fdf07d4 100644 --- a/src/components/views/XmFactorGrid.vue +++ b/src/components/views/XmFactorGrid.vue @@ -4,6 +4,7 @@ import { AgGridVue } from 'ag-grid-vue3' import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community' import localforage from 'localforage' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' +import { parseNumberOrNull } from '@/lib/number' import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' interface DictItem { @@ -41,12 +42,6 @@ const props = defineProps<{ const detailRows = ref([]) const gridApi = ref | null>(null) -const parseNumberOrNull = (value: unknown) => { - if (value === '' || value == null) return null - const v = Number(value) - return Number.isFinite(v) ? v : null -} - const formatReadonlyFactor = (value: unknown) => { if (value == null || value === '') return '' return Number(value).toFixed(2) @@ -277,7 +272,7 @@ onBeforeUnmount(() => {

{{ title }}

-
导入导出
+
diff --git a/src/components/views/htInfo.vue b/src/components/views/htInfo.vue index cec1bca..16d8fbd 100644 --- a/src/components/views/htInfo.vue +++ b/src/components/views/htInfo.vue @@ -327,7 +327,7 @@ const processCellFromClipboard = (params:any) => {

合同规模明细

-
导入导出
+
diff --git a/src/components/views/pricingView/HourlyPricingPane.vue b/src/components/views/pricingView/HourlyPricingPane.vue index ad3104e..f0ebeb4 100644 --- a/src/components/views/pricingView/HourlyPricingPane.vue +++ b/src/components/views/pricingView/HourlyPricingPane.vue @@ -7,6 +7,7 @@ import { expertList } from '@/sql' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { formatThousands } from '@/lib/numberFormat' +import { parseNumberOrNull } from '@/lib/number' import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' @@ -173,12 +174,6 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => }) } -const parseNumberOrNull = (value: unknown) => { - if (value === '' || value == null) return null - const v = Number(value) - return Number.isFinite(v) ? v : null -} - const parseNonNegativeIntegerOrNull = (value: unknown) => { if (value === '' || value == null) return null if (typeof value === 'number') { @@ -482,7 +477,7 @@ const handleGridReady = (params: any) => {

工时法明细

-
导入导出
+
diff --git a/src/components/views/pricingView/InvestmentScalePricingPane.vue b/src/components/views/pricingView/InvestmentScalePricingPane.vue index 0e5a6d4..c7493b1 100644 --- a/src/components/views/pricingView/InvestmentScalePricingPane.vue +++ b/src/components/views/pricingView/InvestmentScalePricingPane.vue @@ -3,13 +3,15 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { AgGridVue } from 'ag-grid-vue3' import type { ColDef } from 'ag-grid-community' import localforage from 'localforage' -import { getBasicFeeFromScale, majorList } from '@/sql' +import { majorList } from '@/sql' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' -import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' +import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { formatThousands } from '@/lib/numberFormat' import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' +import { parseNumberOrNull } from '@/lib/number' +import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee' import { Button } from '@/components/ui/button' import { AlertDialogAction, @@ -250,12 +252,6 @@ const mergeWithDictRows = ( }) } -const parseNumberOrNull = (value: unknown) => { - if (value === '' || value == null) return null - const v = Number(value) - return Number.isFinite(v) ? v : null -} - const formatEditableNumber = (params: any) => { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { return '点击输入' @@ -285,15 +281,15 @@ const formatReadonlyMoney = (params: any) => { return formatThousands(roundTo(params.value, 2)) } -const getBenchmarkBudgetByAmount = (row?: Pick) => { - const result = getBasicFeeFromScale(row?.amount, 'cost') - return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null -} +const getBenchmarkBudgetByAmount = (row?: Pick) => + getBenchmarkBudgetByScale(row?.amount, 'cost') const getBudgetFee = (row?: Pick) => { - const benchmarkBudget = getBenchmarkBudgetByAmount(row) - 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), + majorFactor: row?.majorFactor, + consultCategoryFactor: row?.consultCategoryFactor + }) } const columnDefs: ColDef[] = [ diff --git a/src/components/views/pricingView/LandScalePricingPane.vue b/src/components/views/pricingView/LandScalePricingPane.vue index 99df92f..b75366c 100644 --- a/src/components/views/pricingView/LandScalePricingPane.vue +++ b/src/components/views/pricingView/LandScalePricingPane.vue @@ -3,13 +3,15 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue' import { AgGridVue } from 'ag-grid-vue3' import type { ColDef } from 'ag-grid-community' import localforage from 'localforage' -import { getBasicFeeFromScale, majorList } from '@/sql' +import { majorList } from '@/sql' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' -import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' +import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { formatThousands } from '@/lib/numberFormat' import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' +import { parseNumberOrNull } from '@/lib/number' +import { getBenchmarkBudgetByScale, getScaleBudgetFee } from '@/lib/pricingScaleFee' import { Button } from '@/components/ui/button' import { AlertDialogAction, @@ -253,12 +255,6 @@ const mergeWithDictRows = ( }) } -const parseNumberOrNull = (value: unknown) => { - if (value === '' || value == null) return null - const v = Number(value) - return Number.isFinite(v) ? v : null -} - const formatEditableNumber = (params: any) => { if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { return '点击输入' @@ -280,15 +276,15 @@ const formatReadonlyMoney = (params: any) => { return formatThousands(roundTo(params.value, 2)) } -const getBenchmarkBudgetByLandArea = (row?: Pick) => { - const result = getBasicFeeFromScale(row?.landArea, 'area') - return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null -} +const getBenchmarkBudgetByLandArea = (row?: Pick) => + getBenchmarkBudgetByScale(row?.landArea, 'area') const getBudgetFee = (row?: Pick) => { - const benchmarkBudget = getBenchmarkBudgetByLandArea(row) - 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), + majorFactor: row?.majorFactor, + consultCategoryFactor: row?.consultCategoryFactor + }) } const formatEditableFlexibleNumber = (params: any) => { diff --git a/src/components/views/pricingView/WorkloadPricingPane.vue b/src/components/views/pricingView/WorkloadPricingPane.vue index 7684c94..ec7e6e8 100644 --- a/src/components/views/pricingView/WorkloadPricingPane.vue +++ b/src/components/views/pricingView/WorkloadPricingPane.vue @@ -7,6 +7,7 @@ import { taskList } from '@/sql' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { formatThousands } from '@/lib/numberFormat' +import { parseNumberOrNull } from '@/lib/number' import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults' @@ -180,12 +181,8 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => }) } -const parseNumberOrNull = (value: unknown) => { - if (value === '' || value == null) return null - const normalized = typeof value === 'string' ? value.replace(/[^0-9.\-]/g, '') : value - const v = Number(normalized) - return Number.isFinite(v) ? v : null -} +const parseSanitizedNumberOrNull = (value: unknown) => + parseNumberOrNull(value, { sanitize: true }) const calcServiceFee = (row: DetailRow | undefined) => { if (!row || isNoTaskRow(row)) return null @@ -281,7 +278,7 @@ const columnDefs: ColDef[] = [ !isNoTaskRow(params.data) && (params.value == null || params.value === '') }, - valueParser: params => parseNumberOrNull(params.newValue), + valueParser: params => parseSanitizedNumberOrNull(params.newValue), valueFormatter: params => { if (isNoTaskRow(params.data)) return '无' if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) { @@ -307,7 +304,7 @@ const columnDefs: ColDef[] = [ (params.value == null || params.value === '') }, aggFunc: decimalAggSum, - valueParser: params => parseNumberOrNull(params.newValue), + valueParser: params => parseSanitizedNumberOrNull(params.newValue), valueFormatter: formatEditableNumber }, { @@ -325,7 +322,7 @@ const columnDefs: ColDef[] = [ !isNoTaskRow(params.data) && (params.value == null || params.value === '') }, - valueParser: params => parseNumberOrNull(params.newValue), + valueParser: params => parseSanitizedNumberOrNull(params.newValue), valueFormatter: formatEditableNumber }, { @@ -511,7 +508,7 @@ const mydiyTheme = myTheme.withParams({

工作量明细

-
导入导出
+
diff --git a/src/components/views/xmInfo.vue b/src/components/views/xmInfo.vue index 7fd6e8e..3dc58a4 100644 --- a/src/components/views/xmInfo.vue +++ b/src/components/views/xmInfo.vue @@ -433,7 +433,7 @@ const scrollToGridSection = () => { > 项目明细 -
导入导出
+
diff --git a/src/lib/decimal.ts b/src/lib/decimal.ts index ceb6fe9..c4306f2 100644 --- a/src/lib/decimal.ts +++ b/src/lib/decimal.ts @@ -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) => { 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 = (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 || []) diff --git a/src/lib/diyAgGridOptions.ts b/src/lib/diyAgGridOptions.ts index 827ef72..95fb6b0 100644 --- a/src/lib/diyAgGridOptions.ts +++ b/src/lib/diyAgGridOptions.ts @@ -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 = { +export const gridOptions: GridOptions = { treeData: true, animateRows: true, tooltipShowMode: 'whenTruncated', @@ -32,13 +27,9 @@ export const gridOptions: GridOptions = { 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'], diff --git a/src/lib/number.ts b/src/lib/number.ts new file mode 100644 index 0000000..0a293ae --- /dev/null +++ b/src/lib/number.ts @@ -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 +} diff --git a/src/lib/numberFormat.ts b/src/lib/numberFormat.ts index 792d516..24092b8 100644 --- a/src/lib/numberFormat.ts +++ b/src/lib/numberFormat.ts @@ -1,19 +1,42 @@ +import { parseNumberOrNull } from '@/lib/number' + +const fixedFormatterCache = new Map() +const flexibleFormatterCache = new Map() + +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) } diff --git a/src/lib/pricingMethodTotals.ts b/src/lib/pricingMethodTotals.ts index 3448d6f..1cbf66a 100644 --- a/src/lib/pricingMethodTotals.ts +++ b/src/lib/pricingMethodTotals.ts @@ -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 { 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 = (rows?: TRow[]) => { + const map = new Map() + for (const row of rows || []) { + map.set(String(row.id), row) + } + return map +} + const getDefaultConsultCategoryFactor = (serviceId: string | number) => { const service = (serviceList as Record)[String(serviceId)] return toFiniteNumberOrNull(service?.defCoe) @@ -95,10 +102,7 @@ const mergeScaleRows = ( serviceId: string | number, rowsFromDb: Array & Pick> | undefined ): ScaleRow[] => { - const dbValueMap = new Map & Pick>() - 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 & Pick> | undefined ): WorkloadRow[] => { - const dbValueMap = new Map & Pick>() - 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 & Pick> | undefined ): HourlyRow[] => { - const dbValueMap = new Map & Pick>() - 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(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) diff --git a/src/lib/pricingScaleFee.ts b/src/lib/pricingScaleFee.ts new file mode 100644 index 0000000..fd6bd52 --- /dev/null +++ b/src/lib/pricingScaleFee.ts @@ -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) +} diff --git a/src/lib/xmFactorDefaults.ts b/src/lib/xmFactorDefaults.ts index ee89961..0caef46 100644 --- a/src/lib/xmFactorDefaults.ts +++ b/src/lib/xmFactorDefaults.ts @@ -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 -const toFiniteNumberOrNull = (value: unknown): number | null => - typeof value === 'number' && Number.isFinite(value) ? value : null - const buildStandardFactorMap = (dict: FactorDict): Map => { const map = new Map() for (const [id, item] of Object.entries(dict)) { diff --git a/src/lib/zxFwPricingSync.ts b/src/lib/zxFwPricingSync.ts index 09a8245..0d76f26 100644 --- a/src/lib/zxFwPricingSync.ts +++ b/src/lib/zxFwPricingSync.ts @@ -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 diff --git a/src/main.ts b/src/main.ts index edde7d7..fa58441 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,18 @@ -import { createApp } from 'vue' -import './style.css' -import App from './App.vue' -import { createPinia } from 'pinia' import { - ModuleRegistry, + CellStyleModule, ClientSideRowModelModule, ColumnAutoSizeModule, CsvExportModule, LargeTextEditorModule, + LocaleModule, + ModuleRegistry, NumberEditorModule, PinnedRowModule, + RowAutoHeightModule, TextEditorModule, TooltipModule, - UndoRedoEditModule,ValidationModule,LocaleModule ,CellStyleModule ,RowAutoHeightModule + UndoRedoEditModule, + ValidationModule } from 'ag-grid-community' import { AggregationModule, @@ -24,18 +24,26 @@ import { RowGroupingModule, TreeDataModule } from 'ag-grid-enterprise' -LicenseManager.setLicenseKey("[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b") -import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' // 引入 -const pinia = createPinia() -pinia.use(piniaPluginPersistedstate) -ModuleRegistry.registerModules([ +import { createPinia } from 'pinia' +import piniaPluginPersistedstate from 'pinia-plugin-persistedstate' +import { createApp } from 'vue' +import App from './App.vue' +import './style.css' + +LicenseManager.setLicenseKey( + '[v3][RELEASE][0102]_NDg2Njc4MzY3MDgzNw==16d78ca762fb5d2ff740aed081e2af7b' +) + +const AG_GRID_MODULES = [ ClientSideRowModelModule, ColumnAutoSizeModule, CsvExportModule, TextEditorModule, - NumberEditorModule,RowAutoHeightModule, + NumberEditorModule, + RowAutoHeightModule, LargeTextEditorModule, - UndoRedoEditModule,CellStyleModule , + UndoRedoEditModule, + CellStyleModule, PinnedRowModule, TooltipModule, TreeDataModule, @@ -44,8 +52,15 @@ ModuleRegistry.registerModules([ MenuModule, CellSelectionModule, ContextMenuModule, - ClipboardModule,LocaleModule , + ClipboardModule, + LocaleModule, ValidationModule -]) +] + +const pinia = createPinia() +pinia.use(piniaPluginPersistedstate) + +// 在应用启动时一次性注册 AG Grid 运行所需模块。 +ModuleRegistry.registerModules(AG_GRID_MODULES) createApp(App).use(pinia).mount('#app') diff --git a/src/pinia/tab.ts b/src/pinia/tab.ts index cff8792..8405b50 100644 --- a/src/pinia/tab.ts +++ b/src/pinia/tab.ts @@ -1,112 +1,115 @@ -// src/stores/tab.ts import { defineStore } from 'pinia' import { ref } from 'vue' -export const useTabStore = defineStore('tabs', () => { - interface TabItem> { - id: string; // 标签唯一标识 - title: string; // 标签标题 - componentName: string; // 组件名称 - props?: T; // 传递给组件的 props(可选,泛型适配不同组件) +export interface TabItem> { + id: string + title: string + componentName: string + props?: TProps } - const defaultTabs :TabItem[]= [ - { id: 'XmView', title: '项目卡片', componentName: 'XmView' } - ] - const tabs = ref([ - ...defaultTabs - ]) - const activeTabId = ref('XmView') +const HOME_TAB_ID = 'XmView' +const DEFAULT_TAB: TabItem = { + id: HOME_TAB_ID, + title: '项目卡片', + componentName: HOME_TAB_ID +} - const ensureActiveValid = () => { - const activeExists = tabs.value.some(t => t.id === activeTabId.value) - if (!activeExists) { - activeTabId.value = tabs.value[0]?.id || 'XmView' - } - } +const createDefaultTabs = (): TabItem[] => [{ ...DEFAULT_TAB }] - const openTab = (config: { id: string; title: string; componentName: string; props?: any }) => { - const exists = tabs.value.some(t => t.id === config.id) - if (!exists) { - tabs.value = [...tabs.value, config] - } - activeTabId.value = config.id - } +export const useTabStore = defineStore( + 'tabs', + () => { + const tabs = ref(createDefaultTabs()) + const activeTabId = ref(HOME_TAB_ID) - const removeTab = (id: string) => { - if (id === 'XmView') return // 首页不可删除 - const index = tabs.value.findIndex(t => t.id === id) - if (index < 0) return - const wasActive = activeTabId.value === id - tabs.value = tabs.value.filter(t => t.id !== id) - - if (tabs.value.length === 0) { - tabs.value = [...defaultTabs] + const ensureHomeTab = () => { + if (tabs.value.some(tab => tab.id === HOME_TAB_ID)) return + tabs.value = [...createDefaultTabs(), ...tabs.value] } - if (wasActive) { - const fallbackIndex = Math.max(0, Math.min(index - 1, tabs.value.length - 1)) - activeTabId.value = tabs.value[fallbackIndex]?.id || 'XmView' - return + const ensureActiveValid = () => { + ensureHomeTab() + if (tabs.value.length === 0) tabs.value = createDefaultTabs() + if (!tabs.value.some(tab => tab.id === activeTabId.value)) { + activeTabId.value = tabs.value[0]?.id ?? HOME_TAB_ID + } } - const activeStillExists = tabs.value.some(t => t.id === activeTabId.value) - if (!activeStillExists) { - activeTabId.value = tabs.value[0]?.id || 'XmView' + const openTab = (config: TabItem) => { + if (!tabs.value.some(tab => tab.id === config.id)) { + tabs.value = [...tabs.value, config] + } + activeTabId.value = config.id + } + + const removeTab = (id: string) => { + // 首页标签固定保留,不允许关闭。 + if (id === HOME_TAB_ID) return + + const index = tabs.value.findIndex(tab => tab.id === id) + if (index < 0) return + + const wasActive = activeTabId.value === id + tabs.value = tabs.value.filter(tab => tab.id !== id) + ensureHomeTab() + + if (wasActive) { + const fallbackIndex = Math.max(0, Math.min(index - 1, tabs.value.length - 1)) + activeTabId.value = tabs.value[fallbackIndex]?.id ?? HOME_TAB_ID + return + } + + ensureActiveValid() + } + + const closeAllTabs = () => { + tabs.value = createDefaultTabs() + activeTabId.value = HOME_TAB_ID + } + + const closeLeftTabs = (targetId: string) => { + const targetIndex = tabs.value.findIndex(tab => tab.id === targetId) + if (targetIndex < 0) return + tabs.value = tabs.value.filter((tab, index) => tab.id === HOME_TAB_ID || index >= targetIndex) + ensureActiveValid() + } + + const closeRightTabs = (targetId: string) => { + const targetIndex = tabs.value.findIndex(tab => tab.id === targetId) + if (targetIndex < 0) return + tabs.value = tabs.value.filter((tab, index) => tab.id === HOME_TAB_ID || index <= targetIndex) + ensureActiveValid() + } + + const closeOtherTabs = (targetId: string) => { + tabs.value = tabs.value.filter(tab => tab.id === HOME_TAB_ID || tab.id === targetId) + ensureHomeTab() + activeTabId.value = tabs.value.some(tab => tab.id === targetId) ? targetId : HOME_TAB_ID + } + + const resetTabs = () => { + tabs.value = createDefaultTabs() + activeTabId.value = HOME_TAB_ID + } + + return { + tabs, + activeTabId, + openTab, + removeTab, + closeAllTabs, + closeLeftTabs, + closeRightTabs, + closeOtherTabs, + resetTabs + } + }, + { + persist: { + key: 'tabs', + storage: localStorage, + pick: ['tabs', 'activeTabId'] } } - - const closeAllTabs = () => { - tabs.value = tabs.value.filter(t => t.id === 'XmView') - if (tabs.value.length === 0) tabs.value = [...defaultTabs] - activeTabId.value = 'XmView' - } - - const closeLeftTabs = (targetId: string) => { - const targetIndex = tabs.value.findIndex(t => t.id === targetId) - if (targetIndex < 0) return - tabs.value = tabs.value.filter((tab, index) => tab.id === 'XmView' || index >= targetIndex) - ensureActiveValid() - } - - const closeRightTabs = (targetId: string) => { - const targetIndex = tabs.value.findIndex(t => t.id === targetId) - if (targetIndex < 0) return - tabs.value = tabs.value.filter((tab, index) => tab.id === 'XmView' || index <= targetIndex) - ensureActiveValid() - } - - const closeOtherTabs = (targetId: string) => { - tabs.value = tabs.value.filter(tab => tab.id === 'XmView' || tab.id === targetId) - if (tabs.value.length === 0) tabs.value = [...defaultTabs] - if (targetId === 'XmView') { - activeTabId.value = 'XmView' - return - } - activeTabId.value = tabs.value.some(t => t.id === targetId) ? targetId : 'XmView' - } - - const resetTabs = () => { - tabs.value = [...defaultTabs] - activeTabId.value = 'XmView' - } - - return { - tabs, - activeTabId, - openTab, - removeTab, - closeAllTabs, - closeLeftTabs, - closeRightTabs, - closeOtherTabs, - resetTabs - } -}, { - // --- 关键配置:开启持久化 --- - persist: { - key: 'tabs', // 存储在 localStorage 里的 key - storage: localStorage, // 也可以改用 sessionStorage - pick: ['tabs', 'activeTabId'], // 指定哪些变量需要持久化 - } -}) +) diff --git a/src/sql.ts b/src/sql.ts index efe7a4c..90617fb 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -53,8 +53,8 @@ export const serviceList = { 14: { code: 'D4', name: '专项造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '' }, 15: { code: 'D4-1', name: '工程造价顾问', maxCoe: null, minCoe: null, defCoe: 1, desc: '本表系数适用于采用工作量计价法基准预算的调整系数。' }, 16: { code: 'D4-2', name: '造价政策制(修)订', maxCoe: null, minCoe: null, defCoe: 1, desc: '' }, - 17: { code: 'D4-3', name: '造价科学与技术研究', maxCoe: null, minCoe: null, defCoe: 1, desc: ''}, - 18: { code: 'D4-4', name: '定额测定', maxCoe: null, minCoe: null, defCoe: 1, desc: ''}, + 17: { code: 'D4-3', name: '造价科学与技术研究', maxCoe: null, minCoe: null, defCoe: 1, desc: '' }, + 18: { code: 'D4-4', name: '定额测定', maxCoe: null, minCoe: null, defCoe: 1, desc: '' }, 19: { code: 'D4-5', name: '造价信息咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '' }, 20: { code: 'D4-6', name: '造价鉴定', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '本表系数适用于采用规模计价法基准预算的调整系数。' }, 21: { code: 'D4-7', name: '工程成本测算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '' }, @@ -63,12 +63,12 @@ export const serviceList = { 24: { code: 'D4-10', name: '工程变更费用咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '' }, 25: { code: 'D4-11', name: '调整估算', maxCoe: 0.2, minCoe: 0.1, defCoe: 0.15, desc: '' }, 26: { code: 'D4-12', name: '调整概算', maxCoe: 0.3, minCoe: 0.15, defCoe: 0.225, desc: '本表系数适用于采用规模计价法基准预算的系数;依据其调整时期所在建设阶段和基础资料的不同,其系数取值不同。' }, - 27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。' ,notshowByzxflxs:true}, - 28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。' ,notshowByzxflxs:true}, + 27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。', notshowByzxflxs: true }, + 28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。', notshowByzxflxs: true }, }; //basicParam预算基数 -export const taskList = { +export const taskList = { 0: { serviceID: 15, ref: 'C4-1', name: '工程造价日常顾问', basicParam: '服务月份数', required: true, unit: '万元/月', conversion: 10000, maxPrice: 0.5, minPrice: 0.3, defPrice: 0.4, desc: '' }, 1: { serviceID: 15, ref: 'C4-2', name: '工程造价专项顾问', basicParam: '服务项目的造价金额', required: true, unit: '%', conversion: 0.01, maxPrice: null, minPrice: null, defPrice: 0.01, desc: '适用于涉及造价费用类的顾问' }, 2: { serviceID: 16, ref: 'C5-1', name: '组织与调研工作', basicParam: '调研次数', required: true, unit: '万元/次', conversion: 10000, maxPrice: 2, minPrice: 1, defPrice: 1.5, desc: '' }, @@ -124,7 +124,7 @@ export const expertList = { 7: { code: 'C9-3-2', name: '一级造价工程师且具备高级工程师资格', maxPrice: 2000, minPrice: 1800, defPrice: 1900, manageCoe: 2.05 }, }; - const costScaleCal = [ +const costScaleCal = [ { code: 'C1-1', staLine: 0, endLine: 100, basic: { staPrice: 0, rate: 0.01 }, optional: { staPrice: 0, rate: 0.002 } }, { code: 'C1-2', staLine: 100, endLine: 300, basic: { staPrice: 10000, rate: 0.008 }, optional: { staPrice: 2000, rate: 0.0016 } }, { code: 'C1-3', staLine: 300, endLine: 500, basic: { staPrice: 26000, rate: 0.005 }, optional: { staPrice: 5200, rate: 0.001 } }, @@ -144,7 +144,7 @@ export const expertList = { { code: 'C1-17', staLine: 1000000, endLine: null, basic: { staPrice: 5906000, rate: 0.00025 }, optional: { staPrice: 1181200, rate: 0.00005 } }, ]; - const areaScaleCal = [ +const areaScaleCal = [ { code: 'C2-1', staLine: 0, endLine: 50, basic: { staPrice: 0, rate: 200 }, optional: { staPrice: 0, rate: 40 } }, { code: 'C2-2', staLine: 50, endLine: 100, basic: { staPrice: 10000, rate: 160 }, optional: { staPrice: 2000, rate: 32 } }, { code: 'C2-3', staLine: 100, endLine: 500, basic: { staPrice: 18000, rate: 120 }, optional: { staPrice: 3600, rate: 24 } }, @@ -238,7 +238,7 @@ export function getBasicFeeFromScale(scaleValue: unknown, scaleType: 'cost' | 'a } -async function exportFile(fileName, data) { +export async function exportFile(fileName, data) { if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: fileName, @@ -817,15 +817,15 @@ function cloneCellValue(value) { } - +//demo let data1 = { - name: 'test001', - fee: 10000, - scale: [ + name: 'test001',//项目名称 + fee: 10000, //所有合同段总费用 + scale: [//项目明细aggrid数据 { - major: 0, - cost: 100000, - area: 200, + major: 0, //专业id,对应专业majorList中的key + cost: 100000,//造价金额 + area: 200,//用地面积 }, { major: 1, @@ -833,15 +833,15 @@ let data1 = { area: 200, }, ], - contracts: [ + contracts: [//合同段数据 { - name: 'A合同段', - fee: 10000, - scale: [ + name: 'A合同段',//合同段名称 + fee: 10000,//合同段费用(该合同段咨询服务zxfw.vue里面aggrid的合同预算行的小计) + scale: [//合同段明细aggrid数据(htinfo.vue) { - major: 0, - cost: 100000, - area: 200, + major: 0, //专业id,对应专业majorList中的key + cost: 100000,//造价金额 + area: 200,//用地面积 }, { major: 1, @@ -849,12 +849,12 @@ let data1 = { area: 200, }, ], - services: [ + services: [//咨询服务数据(zxfw.vue里面aggrid的数据,不用输出合同预算) { - id: 0, - fee: 100000, - method1: { // 投资规模法 - cost: 100000, + id: 0, //服务id,对应serviceList中的key + fee: 100000 ,//服务费用(该服务咨询服务小计) + method1: { // 投资规模法InvestmentScalePricingPane.vue的数据 + cost: 100000, //zxfw.vue里面aggrid该数据的投资规模法金额 basicFee: 200, basicFee_basic: 200, basicFee_optional: 0,