From 043e1fc879a950e2ec47ad15634277fd8014d322 Mon Sep 17 00:00:00 2001 From: wintsa <770775984@qq.com> Date: Sat, 7 Mar 2026 15:32:54 +0800 Subject: [PATCH] fix --- src/components/common/HtFeeGrid.vue | 17 ++ .../views/XmConsultCategoryFactor.vue | 57 ++++- src/components/views/ZxFwView.vue | 92 +++++++- src/components/views/htCard.vue | 3 +- .../InvestmentScalePricingPane.vue | 146 +++++++++--- src/components/views/zxFw.vue | 219 ++++++++++++++---- src/lib/pricingMethodTotals.ts | 83 ++++++- src/sql.ts | 24 +- 8 files changed, 529 insertions(+), 112 deletions(-) diff --git a/src/components/common/HtFeeGrid.vue b/src/components/common/HtFeeGrid.vue index aa01d26..76a3606 100644 --- a/src/components/common/HtFeeGrid.vue +++ b/src/components/common/HtFeeGrid.vue @@ -123,6 +123,23 @@ const loadFromIndexedDB = async () => { } const columnDefs: ColDef[] = [ + { + headerName: '序号', + colId: 'rowNo', + minWidth: 68, + maxWidth: 80, + flex: 0.6, + editable: false, + sortable: false, + filter: false, + cellStyle: { textAlign: 'center' }, + valueGetter: params => + params.node?.rowPinned + ? '' + : typeof params.node?.rowIndex === 'number' + ? params.node.rowIndex + 1 + : '' + }, { headerName: '费用项', field: 'feeItem', diff --git a/src/components/views/XmConsultCategoryFactor.vue b/src/components/views/XmConsultCategoryFactor.vue index e5b13a1..cc307c9 100644 --- a/src/components/views/XmConsultCategoryFactor.vue +++ b/src/components/views/XmConsultCategoryFactor.vue @@ -1,14 +1,55 @@ diff --git a/src/components/views/ZxFwView.vue b/src/components/views/ZxFwView.vue index 2ad7b71..b205493 100644 --- a/src/components/views/ZxFwView.vue +++ b/src/components/views/ZxFwView.vue @@ -5,20 +5,29 @@ :subtitle="`合同ID:${contractId}`" :copy-text="contractId" :storage-key="`zxfw-pricing-active-cat-${contractId}-${serviceId}`" - default-category="investment-scale-method" + :default-category="defaultCategory" :categories="pricingCategories" /> diff --git a/src/components/views/htCard.vue b/src/components/views/htCard.vue index 0fcdb61..1d0ebcd 100644 --- a/src/components/views/htCard.vue +++ b/src/components/views/htCard.vue @@ -124,9 +124,10 @@ const xmCategories: XmCategoryItem[] = [ { key: 'info', label: '规模信息', component: htView }, { key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView }, { key: 'major-factor', label: '工程专业系数', component: majorFactorView }, + { key: 'contract', label: '咨询服务', component: zxfwView }, + { key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView }, { key: 'reserve-fee', label: '预备费', component: reserveFeeView }, - { key: 'contract', label: '咨询服务', component: zxfwView }, ]; diff --git a/src/components/views/pricingView/InvestmentScalePricingPane.vue b/src/components/views/pricingView/InvestmentScalePricingPane.vue index 7e2b6f0..ba826f8 100644 --- a/src/components/views/pricingView/InvestmentScalePricingPane.vue +++ b/src/components/views/pricingView/InvestmentScalePricingPane.vue @@ -3,7 +3,7 @@ import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'v import { AgGridVue } from 'ag-grid-vue3' import type { ColDef, ColGroupDef } from 'ag-grid-community' import localforage from 'localforage' -import { getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql' +import { getMajorDictEntries, getServiceDictItemById, isMajorIdInIndustryScope } from '@/sql' import { myTheme, gridOptions } from '@/lib/diyAgGridOptions' import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal' import { formatThousands } from '@/lib/numberFormat' @@ -78,6 +78,10 @@ interface XmBaseInfoState { projectIndustry?: string } +interface ServiceLite { + onlyCostScale?: boolean | null +} + const props = defineProps<{ contractId: string, serviceId: string | number @@ -95,11 +99,16 @@ const consultCategoryFactorMap = ref>(new Map()) const majorFactorMap = ref>(new Map()) let factorDefaultsLoaded = false const paneInstanceCreatedAt = Date.now() +const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' const getDefaultConsultCategoryFactor = () => consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null +const isOnlyCostScaleService = computed(() => { + const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined + return service?.onlyCostScale === true +}) const loadFactorDefaults = async () => { const [consultMap, majorMap] = await Promise.all([ @@ -184,12 +193,17 @@ const detailDict: DictGroup[] = (() => { }) } + const hasCost = item.hasCost !== false + const hasArea = item.hasArea !== false + // 特殊规则:投资规模法中,hasCost && hasArea 的专业不参与明细行 + if (hasCost && hasArea) continue + groupMap.get(parentCode)!.children.push({ id: key, code, name: item.name, - hasCost: item.hasCost !== false, - hasArea: item.hasArea !== false + hasCost, + hasArea }) } @@ -241,6 +255,48 @@ const buildDefaultRows = (): DetailRow[] => { return rows } +const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) => + sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null)) + +const buildOnlyCostScaleRow = ( + amount: number | null, + fromDb?: Partial> +): DetailRow => ({ + id: ONLY_COST_SCALE_ROW_ID, + groupCode: 'TOTAL', + groupName: '总投资', + majorCode: 'TOTAL', + majorName: '总投资', + hasCost: true, + hasArea: false, + amount, + benchmarkBudget: null, + benchmarkBudgetBasic: null, + benchmarkBudgetOptional: null, + benchmarkBudgetBasicChecked: true, + benchmarkBudgetOptionalChecked: true, + basicFormula: '', + optionalFormula: '', + consultCategoryFactor: + typeof fromDb?.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : getDefaultConsultCategoryFactor(), + majorFactor: typeof fromDb?.majorFactor === 'number' ? fromDb.majorFactor : 1, + workStageFactor: typeof fromDb?.workStageFactor === 'number' ? fromDb.workStageFactor : 1, + workRatio: typeof fromDb?.workRatio === 'number' ? fromDb.workRatio : 100, + budgetFee: null, + budgetFeeBasic: null, + budgetFeeOptional: null, + remark: typeof fromDb?.remark === 'string' ? fromDb.remark : '', + path: [ONLY_COST_SCALE_ROW_ID] +}) + +const buildOnlyCostScaleRows = ( + rowsFromDb?: Array & Pick> +): DetailRow[] => { + const totalAmount = calcOnlyCostScaleAmountFromRows(rowsFromDb) + const onlyRow = rowsFromDb?.find(row => String(row.id) === ONLY_COST_SCALE_ROW_ID) + return [buildOnlyCostScaleRow(totalAmount, onlyRow)] +} + type SourceRow = Pick & Partial< Pick< @@ -340,6 +396,11 @@ const formatMajorFactor = (params: any) => { } const formatEditableMoney = (params: any) => { + if (isOnlyCostScaleService.value) { + if (!params.node?.rowPinned) return '' + if (params.value == null || params.value === '') return '点击输入' + return formatThousands(params.value) + } if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasCost) { return '' } @@ -432,14 +493,21 @@ const columnDefs: Array | ColGroupDef> = [ minWidth: 90, flex: 2, - editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost), + editable: params => { + if (isOnlyCostScaleService.value) return Boolean(params.node?.rowPinned) + return !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) + }, cellClass: params => - !params.node?.group && !params.node?.rowPinned && params.data?.hasCost + isOnlyCostScaleService.value && params.node?.rowPinned + ? 'ag-right-aligned-cell editable-cell-line' + : !params.node?.group && !params.node?.rowPinned && params.data?.hasCost ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell', cellClassRules: { 'editable-cell-empty': params => - !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '') + isOnlyCostScaleService.value && params.node?.rowPinned + ? params.value == null || params.value === '' + : !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '') }, aggFunc: decimalAggSum, valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }), @@ -613,13 +681,13 @@ const autoGroupColumnDef: ColDef = { }, valueFormatter: params => { if (params.node?.rowPinned) { - return '总合计' + return isOnlyCostScaleService.value ? '总投资' : '总合计' } const nodeId = String(params.value || '') return idLabelMap.get(nodeId) || nodeId }, tooltipValueGetter: params => { - if (params.node?.rowPinned) return '总合计' + if (params.node?.rowPinned) return isOnlyCostScaleService.value ? '总投资' : '总合计' const nodeId = String(params.value || '') return idLabelMap.get(nodeId) || nodeId } @@ -627,6 +695,7 @@ const autoGroupColumnDef: ColDef = { const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount)) +const visibleDetailRows = computed(() => (isOnlyCostScaleService.value ? [] : detailRows.value)) const totalBenchmarkBudgetBasic = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByAmount(row)?.basic)) const totalBenchmarkBudgetOptional = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByAmount(row)?.optional)) @@ -727,13 +796,17 @@ const loadFromIndexedDB = async () => { const applyContractDefaultRows = async () => { const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0 - detailRows.value = hasContractRows - ? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) - : buildDefaultRows().map(row => ({ - ...row, - consultCategoryFactor: getDefaultConsultCategoryFactor(), - majorFactor: getDefaultMajorFactorById(row.id) - })) + if (isOnlyCostScaleService.value) { + detailRows.value = hasContractRows ? buildOnlyCostScaleRows(htData!.detailRows as any) : buildOnlyCostScaleRows() + } else { + detailRows.value = hasContractRows + ? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) + : buildDefaultRows().map(row => ({ + ...row, + consultCategoryFactor: getDefaultConsultCategoryFactor(), + majorFactor: getDefaultMajorFactorById(row.id) + })) + } syncComputedValuesToDetailRows() } if (shouldForceDefaultLoad()) { @@ -743,7 +816,9 @@ const loadFromIndexedDB = async () => { const data = await localforage.getItem(DB_KEY.value) if (data) { - detailRows.value = mergeWithDictRows(data.detailRows) + detailRows.value = isOnlyCostScaleService.value + ? buildOnlyCostScaleRows(data.detailRows as any) + : mergeWithDictRows(data.detailRows) syncComputedValuesToDetailRows() return } @@ -751,7 +826,7 @@ const loadFromIndexedDB = async () => { await applyContractDefaultRows() } catch (error) { console.error('loadFromIndexedDB failed:', error) - detailRows.value = buildDefaultRows() + detailRows.value = isOnlyCostScaleService.value ? buildOnlyCostScaleRows() : buildDefaultRows() syncComputedValuesToDetailRows() } } @@ -766,13 +841,17 @@ const importContractData = async () => { await loadFactorDefaults() const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0 - detailRows.value = hasContractRows - ? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) - : buildDefaultRows().map(row => ({ - ...row, - consultCategoryFactor: getDefaultConsultCategoryFactor(), - majorFactor: getDefaultMajorFactorById(row.id) - })) + if (isOnlyCostScaleService.value) { + detailRows.value = hasContractRows ? buildOnlyCostScaleRows(htData!.detailRows as any) : buildOnlyCostScaleRows() + } else { + detailRows.value = hasContractRows + ? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true }) + : buildDefaultRows().map(row => ({ + ...row, + consultCategoryFactor: getDefaultConsultCategoryFactor(), + majorFactor: getDefaultMajorFactorById(row.id) + })) + } await saveToIndexedDB() } catch (error) { console.error('importContractData failed:', error) @@ -781,7 +860,7 @@ const importContractData = async () => { const clearAllData = async () => { try { - detailRows.value = buildDefaultRows() + detailRows.value = isOnlyCostScaleService.value ? buildOnlyCostScaleRows() : buildDefaultRows() await saveToIndexedDB() } catch (error) { console.error('clearAllData failed:', error) @@ -802,7 +881,20 @@ let persistTimer: ReturnType | null = null let gridPersistTimer: ReturnType | null = null -const handleCellValueChanged = () => { +const applyOnlyCostScalePinnedAmount = (rawValue: unknown) => { + const amount = parseNumberOrNull(rawValue, { precision: 2 }) + const current = detailRows.value[0] + if (!current) { + detailRows.value = [buildOnlyCostScaleRow(amount)] + return + } + detailRows.value = [{ ...current, amount }] +} + +const handleCellValueChanged = (event?: any) => { + if (isOnlyCostScaleService.value && event?.node?.rowPinned && event.colDef?.field === 'amount') { + applyOnlyCostScalePinnedAmount(event.newValue) + } syncComputedValuesToDetailRows() if (gridPersistTimer) clearTimeout(gridPersistTimer) gridPersistTimer = setTimeout(() => { @@ -899,7 +991,7 @@ const processCellFromClipboard = (params: any) => {
- -import { computed, defineComponent, h, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue' +import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue' import type { ComponentPublicInstance, PropType } from 'vue' import { AgGridVue } from 'ag-grid-vue3' import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community' @@ -9,7 +9,11 @@ import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale' import { addNumbers } from '@/lib/decimal' import { parseNumberOrNull } from '@/lib/number' import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat' -import { getPricingMethodTotalsForService, getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals' +import { + getPricingMethodTotalsForService, + getPricingMethodTotalsForServices, + type PricingMethodTotals +} from '@/lib/pricingMethodTotals' import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { Pencil, Eraser, Trash2 } from 'lucide-vue-next' import { @@ -21,18 +25,11 @@ import { AlertDialogPortal, AlertDialogRoot, AlertDialogTitle, - DialogClose, - DialogContent, - DialogDescription, - DialogOverlay, - DialogPortal, - DialogRoot, - DialogTitle, - DialogTrigger + } from 'reka-ui' import { Button } from '@/components/ui/button' import { TooltipProvider } from '@/components/ui/tooltip' -import { getServiceDictEntries } from '@/sql' +import { getServiceDictEntries, isIndustryEnabledByType,getIndustryTypeValue } from '@/sql' import { useTabStore } from '@/pinia/tab' import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload' import ServiceCheckboxSelector from '@/components/views/ServiceCheckboxSelector.vue' @@ -41,6 +38,7 @@ interface ServiceItem { id: string code: string name: string + type: ServiceMethodType } interface DetailRow { @@ -61,6 +59,17 @@ interface ZxFwState { detailRows: DetailRow[] } +interface XmBaseInfoState { + projectIndustry?: string +} + +interface ServiceMethodType { + scale?: boolean | null + onlyCostScale?: boolean | null + amount?: boolean | null + workDay?: boolean | null +} + const props = defineProps<{ contractId: string contractName?: string @@ -68,25 +77,66 @@ const props = defineProps<{ const tabStore = useTabStore() const pricingPaneReloadStore = usePricingPaneReloadStore() const DB_KEY = computed(() => `zxFW-${props.contractId}`) +const PROJECT_INFO_KEY = 'xm-base-info-v1' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_CLEAR_SKIP_TTL_MS = 5000 +const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const +const projectIndustry = ref('') -type ServiceListItem = { code?: string; ref?: string; name: string; defCoe: number | null } -const serviceDict: ServiceItem[] = getServiceDictEntries() - .map(({ id, item }) => ({ id, item: item as ServiceListItem })) - .filter(({ item }) => { - const itemCode = item?.code || item?.ref - return Boolean(itemCode && item?.name) && item.defCoe !== null - }) - .map(({ id, item }) => ({ +type ServiceListItem = { + code?: string + ref?: string + name: string + defCoe: number | null + isRoad?: boolean + isRailway?: boolean + isWaterway?: boolean + scale?: boolean | null + onlyCostScale?: boolean | null + amount?: boolean | null + workDay?: boolean | null +} + +const toNullableBoolean = (value: unknown): boolean | null => + typeof value === 'boolean' ? value : null + +const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) => + typeof value === 'boolean' ? value : fallback + +const defaultServiceMethodType = { + scale: true, + onlyCostScale: false, + amount: true, + workDay: true +} + +const serviceDict = computed(() => { + const industry = projectIndustry.value + if (!industry) return [] + const filteredEntries = getServiceDictEntries() + .map(({ id, item }) => ({ id, item: item as ServiceListItem })) + .filter(({ item }) => { + const itemCode = item?.code || item?.ref + return Boolean(itemCode && item?.name) && item.defCoe !== null && isIndustryEnabledByType(item, getIndustryTypeValue(industry)) + + }) + return filteredEntries.map(({ id, item }) => ({ id, code: item.code || item.ref || '', - name: item.name + name: item.name, + type: { + scale: toNullableBoolean(item.scale), + onlyCostScale: toNullableBoolean(item.onlyCostScale), + amount: toNullableBoolean(item.amount), + workDay: toNullableBoolean(item.workDay) + } })) +}) -const serviceById = new Map(serviceDict.map(item => [item.id, item])) -const serviceIdByCode = new Map(serviceDict.map(item => [item.code, item.id])) +const serviceById = computed(() => new Map(serviceDict.value.map(item => [item.id, item]))) +const serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id]))) +const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|')) const fixedBudgetRow: Pick = { id: 'fixed-budget-c', code: 'C', name: '合同预算' } const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id @@ -167,7 +217,7 @@ const startDragAutoScroll = () => { const selectedServiceText = computed(() => { if (selectedIds.value.length === 0) return '' const names = selectedIds.value - .map(id => serviceById.get(id)?.name || '') + .map(id => serviceById.value.get(id)?.name || '') .filter(Boolean) if (names.length <= 2) return names.join('、') return `${names.slice(0, 2).join('、')} 等 ${names.length} 项` @@ -177,7 +227,7 @@ const pendingClearServiceName = computed(() => { if (!pendingClearServiceId.value) return '' const row = detailRows.value.find(item => item.id === pendingClearServiceId.value) if (row) return `${row.code}${row.name}` - const dict = serviceById.get(pendingClearServiceId.value) + const dict = serviceById.value.get(pendingClearServiceId.value) if (dict) return `${dict.code}${dict.name}` return pendingClearServiceId.value }) @@ -186,7 +236,7 @@ const pendingDeleteServiceName = computed(() => { if (!pendingDeleteServiceId.value) return '' const row = detailRows.value.find(item => item.id === pendingDeleteServiceId.value) if (row) return `${row.code}${row.name}` - const dict = serviceById.get(pendingDeleteServiceId.value) + const dict = serviceById.value.get(pendingDeleteServiceId.value) if (dict) return `${dict.code}${dict.name}` return pendingDeleteServiceId.value }) @@ -241,8 +291,8 @@ const confirmDeleteRow = async () => { const filteredServiceDict = computed(() => { const keyword = pickerSearch.value.trim() - if (!keyword) return serviceDict - return serviceDict.filter(item => item.code.includes(keyword) || item.name.includes(keyword)) + if (!keyword) return serviceDict.value + return serviceDict.value.filter(item => item.code.includes(keyword) || item.name.includes(keyword)) }) const dragRectStyle = computed(() => { @@ -265,6 +315,40 @@ const numericParser = (newValue: any): number | null => { const valueOrZero = (v: number | null | undefined) => (typeof v === 'number' ? v : 0) +const getServiceMethodTypeById = (serviceId: string) => { + const type = serviceById.value.get(serviceId)?.type + const scale = resolveMethodEnabled(type?.scale, defaultServiceMethodType.scale) + const onlyCostScale = resolveMethodEnabled(type?.onlyCostScale, defaultServiceMethodType.onlyCostScale) + const amount = resolveMethodEnabled(type?.amount, defaultServiceMethodType.amount) + const workDay = resolveMethodEnabled(type?.workDay, defaultServiceMethodType.workDay) + return { scale, onlyCostScale, amount, workDay } +} + +const sanitizePricingTotalsByService = (serviceId: string, totals: PricingMethodTotals): PricingMethodTotals => { + const methodType = getServiceMethodTypeById(serviceId) + const isScaleEnabled = methodType.scale + const isLandScaleEnabled = isScaleEnabled && !methodType.onlyCostScale + return { + investScale: isScaleEnabled ? totals.investScale : null, + landScale: isLandScaleEnabled ? totals.landScale : null, + workload: methodType.amount ? totals.workload : null, + hourly: methodType.workDay ? totals.hourly : null + } +} + +const sanitizePricingFieldsByService = ( + serviceId: string, + values: Pick +) => { + const sanitized = sanitizePricingTotalsByService(serviceId, values) + return { + investScale: sanitized.investScale, + landScale: sanitized.landScale, + workload: sanitized.workload, + hourly: sanitized.hourly + } +} + const getMethodTotalFromRows = ( rows: DetailRow[], field: 'investScale' | 'landScale' | 'workload' | 'hourly' @@ -313,17 +397,19 @@ const clearRowValues = async (row: DetailRow) => { await clearPricingPaneValues(row.id) const totals = await getPricingMethodTotalsForService({ contractId: props.contractId, - serviceId: row.id + serviceId: row.id, + options: PRICING_TOTALS_OPTIONS }) + const sanitizedTotals = sanitizePricingTotalsByService(row.id, totals) const clearedRows = detailRows.value.map(item => item.id !== row.id ? item : { ...item, - investScale: totals.investScale, - landScale: totals.landScale, - workload: totals.workload, - hourly: totals.hourly + investScale: sanitizedTotals.investScale, + landScale: sanitizedTotals.landScale, + workload: sanitizedTotals.workload, + hourly: sanitizedTotals.hourly } ) const nextInvestScale = getMethodTotalFromRows(clearedRows, 'investScale') @@ -346,6 +432,7 @@ const clearRowValues = async (row: DetailRow) => { } const openEditTab = (row: DetailRow) => { + const serviceType = serviceById.value.get(row.id)?.type tabStore.openTab({ id: `zxfw-edit-${props.contractId}-${row.id}`, title: `服务编辑-${row.code}${row.name}`, @@ -354,7 +441,8 @@ const openEditTab = (row: DetailRow) => { contractId: props.contractId, contractName: props.contractName || '', serviceId: row.id, - fwName: row.code + row.name + fwName: row.code + row.name, + type: serviceType ? { ...serviceType } : undefined } }) } @@ -562,13 +650,15 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => { const totalsByServiceId = await getPricingMethodTotalsForServices({ contractId: props.contractId, - serviceIds: targetIds + serviceIds: targetIds, + options: PRICING_TOTALS_OPTIONS }) const targetSet = new Set(targetIds.map(id => String(id))) const nextRows = detailRows.value.map(row => { if (isFixedRow(row) || !targetSet.has(String(row.id))) return row - const totals = totalsByServiceId.get(String(row.id)) + const totalsRaw = totalsByServiceId.get(String(row.id)) + const totals = totalsRaw ? sanitizePricingTotalsByService(String(row.id), totalsRaw) : null if (!totals) return row return { ...row, @@ -585,29 +675,35 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => { const applySelection = (codes: string[]) => { const prevSelectedSet = new Set(selectedIds.value) const uniqueIds = Array.from(new Set(codes)).filter( - id => serviceById.has(id) && id !== fixedBudgetRow.id + id => serviceById.value.has(id) && id !== fixedBudgetRow.id ) const existingMap = new Map(detailRows.value.map(row => [row.id, row])) const baseRows: DetailRow[] = uniqueIds .map(id => { - const dictItem = serviceById.get(id) + const dictItem = serviceById.value.get(id) if (!dictItem) return null const old = existingMap.get(id) - return { - id: old?.id || id, - code: dictItem.code, - name: dictItem.name, + const nextValues = sanitizePricingFieldsByService(id, { investScale: old?.investScale ?? null, landScale: old?.landScale ?? null, workload: old?.workload ?? null, hourly: old?.hourly ?? null + }) + return { + id: old?.id || id, + code: dictItem.code, + name: dictItem.name, + investScale: nextValues.investScale, + landScale: nextValues.landScale, + workload: nextValues.workload, + hourly: nextValues.hourly } }) .filter((row): row is DetailRow => Boolean(row)) - const orderMap = new Map(serviceDict.map((item, index) => [item.id, index])) + const orderMap = new Map(serviceDict.value.map((item, index) => [item.id, index])) baseRows.sort((a, b) => (orderMap.get(a.id) || 0) - (orderMap.get(b.id) || 0)) const fixedOld = existingMap.get(fixedBudgetRow.id) @@ -730,6 +826,7 @@ const applyDragSelectionByRect = () => { } pickerTempIds.value = serviceDict + .value .map(item => item.id) .filter(id => nextSelectedSet.has(id)) } @@ -797,19 +894,25 @@ const loadFromIndexedDB = async () => { } const idsFromStorage = data.selectedIds - || (data.selectedCodes || []).map(code => serviceIdByCode.get(code)).filter((id): id is string => Boolean(id)) + || (data.selectedCodes || []).map(code => serviceIdByCode.value.get(code)).filter((id): id is string => Boolean(id)) applySelection(idsFromStorage) const savedRowMap = new Map((data.detailRows || []).map(row => [row.id, row])) detailRows.value = detailRows.value.map(row => { const old = savedRowMap.get(row.id) if (!old) return row - return { - ...row, + const nextValues = sanitizePricingFieldsByService(row.id, { investScale: typeof old.investScale === 'number' ? old.investScale : null, landScale: typeof old.landScale === 'number' ? old.landScale : null, workload: typeof old.workload === 'number' ? old.workload : null, hourly: typeof old.hourly === 'number' ? old.hourly : null + }) + return { + ...row, + investScale: nextValues.investScale, + landScale: nextValues.landScale, + workload: nextValues.workload, + hourly: nextValues.hourly } }) detailRows.value = applyFixedRowTotals(detailRows.value) @@ -820,6 +923,17 @@ const loadFromIndexedDB = async () => { } } +const loadProjectIndustry = async () => { + try { + const data = await localforage.getItem(PROJECT_INFO_KEY) + projectIndustry.value = + typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : '' + } catch (error) { + console.error('loadProjectIndustry failed:', error) + projectIndustry.value = '' + } +} + watch( () => pricingPaneReloadStore.getReloadVersion(props.contractId, ZXFW_RELOAD_SERVICE_KEY), (nextVersion, prevVersion) => { @@ -828,6 +942,15 @@ watch( } ) +watch(serviceIdSignature, () => { + const availableIds = new Set(serviceDict.value.map(item => item.id)) + const nextSelectedIds = selectedIds.value.filter(id => availableIds.has(id)) + if (nextSelectedIds.length !== selectedIds.value.length) { + applySelection(nextSelectedIds) + void saveToIndexedDB() + } +}) + let gridPersistTimer: ReturnType | null = null const handleCellValueChanged = () => { if (gridPersistTimer) clearTimeout(gridPersistTimer) @@ -837,6 +960,12 @@ const handleCellValueChanged = () => { } onMounted(async () => { + await loadProjectIndustry() + await loadFromIndexedDB() +}) + +onActivated(async () => { + await loadProjectIndustry() await loadFromIndexedDB() }) diff --git a/src/lib/pricingMethodTotals.ts b/src/lib/pricingMethodTotals.ts index 3a82bad..e64f0ae 100644 --- a/src/lib/pricingMethodTotals.ts +++ b/src/lib/pricingMethodTotals.ts @@ -53,10 +53,13 @@ interface HourlyRow { interface MajorLite { code: string defCoe: number | null + hasCost?: boolean + hasArea?: boolean } interface ServiceLite { defCoe: number | null + onlyCostScale?: boolean | null } interface TaskLite { @@ -77,6 +80,12 @@ export interface PricingMethodTotals { hourly: number | null } +interface PricingMethodTotalsOptions { + excludeInvestmentCostAndAreaRows?: boolean +} + +const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' + const hasOwn = (obj: unknown, key: string) => Object.prototype.hasOwnProperty.call(obj || {}, key) @@ -93,6 +102,11 @@ const getDefaultConsultCategoryFactor = (serviceId: string | number) => { return toFiniteNumberOrNull(service?.defCoe) } +const isOnlyCostScaleService = (serviceId: string | number) => { + const service = (getServiceDictById() as Record)[String(serviceId)] + return service?.onlyCostScale === true +} + const majorById = new Map(getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite])) const majorIdAliasMap = getMajorIdAliasMap() @@ -102,6 +116,15 @@ const getDefaultMajorFactorById = (id: string) => { return toFiniteNumberOrNull(major?.defCoe) } +const isDualScaleMajorById = (id: string) => { + const resolvedId = majorById.has(id) ? id : majorIdAliasMap.get(id) || id + const major = majorById.get(resolvedId) + if (!major) return false + const hasCost = major.hasCost !== false + const hasArea = major.hasArea !== false + return hasCost && hasArea +} + const resolveFactorValue = ( row: { budgetValue?: number | null; standardFactor?: number | null } | undefined, fallback: number | null @@ -227,6 +250,31 @@ const getInvestmentBudgetFee = (row: ScaleRow) => { }) } +const getOnlyCostScaleBudgetFee = ( + serviceId: string, + rowsFromDb: Array> | undefined, + consultCategoryFactorMap?: Map +) => { + const totalAmount = sumByNumber(rowsFromDb || [], row => + typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null + ) + const onlyRow = (rowsFromDb || []).find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) + const consultCategoryFactor = + toFiniteNumberOrNull(onlyRow?.consultCategoryFactor) ?? + consultCategoryFactorMap?.get(String(serviceId)) ?? + getDefaultConsultCategoryFactor(serviceId) + const majorFactor = toFiniteNumberOrNull(onlyRow?.majorFactor) ?? 1 + const workStageFactor = toFiniteNumberOrNull(onlyRow?.workStageFactor) ?? 1 + const workRatio = toFiniteNumberOrNull(onlyRow?.workRatio) ?? 100 + return getScaleBudgetFee({ + benchmarkBudget: getBenchmarkBudgetByAmount(totalAmount), + majorFactor, + consultCategoryFactor, + workStageFactor, + workRatio + }) +} + const getLandBudgetFee = (row: ScaleRow) => { return getScaleBudgetFee({ benchmarkBudget: getBenchmarkBudgetByLandArea(row.landArea), @@ -373,6 +421,7 @@ const resolveScaleRows = ( export const getPricingMethodTotalsForService = async (params: { contractId: string serviceId: string | number + options?: PricingMethodTotalsOptions }): Promise => { const serviceId = String(params.serviceId) const htDbKey = `ht-info-v3-${params.contractId}` @@ -395,16 +444,30 @@ export const getPricingMethodTotalsForService = async (params: { const consultCategoryFactorMap = buildConsultCategoryFactorMap(consultFactorData) const majorFactorMap = buildMajorFactorMap(majorFactorData) + const onlyCostScale = isOnlyCostScaleService(serviceId) // 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。 - const investRows = resolveScaleRows( - serviceId, - investData, - htData, - consultCategoryFactorMap, - majorFactorMap - ) - const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row)) + const excludeInvestmentCostAndAreaRows = params.options?.excludeInvestmentCostAndAreaRows === true + const investScale = onlyCostScale + ? getOnlyCostScaleBudgetFee( + serviceId, + (investData?.detailRows as Array> | undefined) || + (htData?.detailRows as Array> | undefined), + consultCategoryFactorMap + ) + : (() => { + const investRows = resolveScaleRows( + serviceId, + investData, + htData, + consultCategoryFactorMap, + majorFactorMap + ) + return sumByNumber(investRows, row => { + if (excludeInvestmentCostAndAreaRows && isDualScaleMajorById(row.id)) return null + return getInvestmentBudgetFee(row) + }) + })() const landRows = resolveScaleRows( serviceId, @@ -443,13 +506,15 @@ export const getPricingMethodTotalsForService = async (params: { export const getPricingMethodTotalsForServices = async (params: { contractId: string serviceIds: Array + options?: PricingMethodTotalsOptions }) => { const result = new Map() await Promise.all( params.serviceIds.map(async serviceId => { const totals = await getPricingMethodTotalsForService({ contractId: params.contractId, - serviceId + serviceId, + options: params.options }) result.set(String(serviceId), totals) }) diff --git a/src/sql.ts b/src/sql.ts index b51da2c..2cf75e9 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -11,6 +11,12 @@ const toFiniteNumber = (value: unknown) => { const num = Number(value) return Number.isFinite(num) ? num : 0 } + +export const industryTypeList = [ + {id:'0', name: '公路工程', type: 'isRoad' }, + {id:'1', name: '铁路工程', type: 'isRailway' }, + {id:'2', name: '水运工程', type: 'isWaterway' } +] as const export const majorList = { 0: { code: 'E1', name: '交通运输工程通用专业',hideInIndustrySelector: true, maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false }, 1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 2, hasCost: true, hasArea: true }, @@ -19,7 +25,7 @@ export const majorList = { 4: { code: 'E1-4', name: '工程建设其他费', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目的工程建设其他费的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 5, hasCost: true, hasArea: false }, 5: { code: 'E1-5', name: '预备费', maxCoe: null, minCoe: null, defCoe: 1, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 6, hasCost: true, hasArea: false }, 6: { code: 'E1-6', name: '建设期贷款利息', maxCoe: null, minCoe: null, defCoe: 1, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 7, hasCost: true, hasArea: false }, - 7: { code: 'E2', name: '公路工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于公路工程的全过程造价咨询、分阶段造价咨询、投资估算、初步设计概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)', isRoad: true, isRailway: false, isWaterway: false, order: 8, hasCost: false, hasArea: false }, + 7: { code: 'E2', name: '公路工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于公路工程的全过程造价咨询、分阶段造价咨询、投资估算、初步设计概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)', isRoad: true, isRailway: false, isWaterway: false, order: 8, hasCost: false, hasArea: false ,industryId:'0' }, 8: { code: 'E2-1', name: '临时工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于临时工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: false, isWaterway: false, order: 9, hasCost: true, hasArea: false }, 9: { code: 'E2-2', name: '路基工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于路基工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: false, isWaterway: false, order: 10, hasCost: true, hasArea: false }, 10: { code: 'E2-3', name: '路面工程', maxCoe: null, minCoe: null, defCoe: 0.8, desc: '适用于路面工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: false, isWaterway: false, order: 11, hasCost: true, hasArea: false }, @@ -30,7 +36,7 @@ export const majorList = { 15: { code: 'E2-8', name: '交通安全设施工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于交通安全设施工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: false, isWaterway: false, order: 16, hasCost: true, hasArea: false }, 16: { code: 'E2-9', name: '绿化及环境保护工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于绿化工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: false, isWaterway: false, order: 17, hasCost: true, hasArea: false }, 17: { code: 'E2-10', name: '房建工程', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房建工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: false, isWaterway: false, order: 18, hasCost: true, hasArea: false }, - 18: { code: 'E3', name: '铁路工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于铁路工程的投资估算、初步设计概算、清理概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)', isRoad: false, isRailway: true, isWaterway: false, order: 19, hasCost: false, hasArea: false }, + 18: { code: 'E3', name: '铁路工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于铁路工程的投资估算、初步设计概算、清理概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)', isRoad: false, isRailway: true, isWaterway: false, order: 19, hasCost: false, hasArea: false ,industryId:'1' }, 19: { code: 'E3-1', name: '大型临时设施和过渡工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于大型临时设施和过渡工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: true, isWaterway: false, order: 20, hasCost: true, hasArea: false }, 20: { code: 'E3-2', name: '路基工程', maxCoe: null, minCoe: null, defCoe: 1.2, desc: '适用于路基工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: true, isWaterway: false, order: 21, hasCost: true, hasArea: false }, 21: { code: 'E3-3', name: '桥涵工程', maxCoe: null, minCoe: null, defCoe: 0.9, desc: '适用于桥涵工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: true, isWaterway: false, order: 22, hasCost: true, hasArea: false }, @@ -40,7 +46,7 @@ export const majorList = { 25: { code: 'E3-7', name: '电力及电力牵引供电工程', maxCoe: null, minCoe: null, defCoe: 1.5, desc: '适用于电力及电力牵引供电工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: true, isWaterway: false, order: 26, hasCost: true, hasArea: false }, 26: { code: 'E3-8', name: '房建工程(房屋建筑及附属工程)', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于房屋建筑及附属工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: true, isWaterway: false, order: 27, hasCost: true, hasArea: false }, 27: { code: 'E3-9', name: '装饰装修工程', maxCoe: null, minCoe: null, defCoe: 2.7, desc: '适用于装饰装修工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: true, isWaterway: false, order: 28, hasCost: true, hasArea: false }, - 28: { code: 'E4', name: '水运工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于水运工程的投资估算、初步设计概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)', isRoad: false, isRailway: false, isWaterway: true, order: 29, hasCost: false, hasArea: false }, + 28: { code: 'E4', name: '水运工程专业', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于水运工程的投资估算、初步设计概算、竣工决算和调整估算、调整概算(含征地拆迁和工程建设其他费)', isRoad: false, isRailway: false, isWaterway: true, order: 29, hasCost: false, hasArea: false ,industryId:'2' }, 29: { code: 'E4-1', name: '临时工程', maxCoe: null, minCoe: null, defCoe: 1.1, desc: '适用于临时工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算、竣工决算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: false, isWaterway: true, order: 30, hasCost: true, hasArea: false }, 30: { code: 'E4-2', name: '土建工程', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于土建工程专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算、竣工决算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: false, isWaterway: true, order: 31, hasCost: true, hasArea: false }, 31: { code: 'E4-3', name: '机电与金属结构工程', maxCoe: null, minCoe: null, defCoe: 1.5, desc: '适用于机电与金属结构专业的施工图预算、招标工程量清单及清单预算(或最高投标限价)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: false, isRailway: false, isWaterway: true, order: 32, hasCost: true, hasArea: false }, @@ -176,11 +182,7 @@ let areaScaleCal = [ -export const industryTypeList = [ - {id:'0', name: '公路工程', type: 'isRoad' }, - {id:'1', name: '铁路工程', type: 'isRailway' }, - {id:'2', name: '水运工程', type: 'isWaterway' } -] as const + export type IndustryType = (typeof industryTypeList)[number]['type'] type DictItem = Record @@ -314,12 +316,6 @@ export const getMajorDictEntries = (): DictEntry[] => buildDictEntries(majorList */ export const getServiceDictEntries = (): DictEntry[] => buildDictEntries(serviceList as Record) -/** - * 判断字典项是否属于某行业(基于 isRoad/isRailway/isWaterway)。 - * @returns 是否属于该行业 - */ -export const isDictItemInIndustryScope = (item: DictItem | undefined, industryCode: unknown): boolean => - isIndustryEnabledByType(item, getIndustryTypeValue(industryCode)) /** * 构建“专业ID -> 专业项”映射。