Compare commits

..

2 Commits

Author SHA1 Message Date
303f54bb71 if 2026-03-07 16:09:06 +08:00
043e1fc879 fix 2026-03-07 15:32:54 +08:00
9 changed files with 686 additions and 155 deletions

View File

@ -123,6 +123,23 @@ const loadFromIndexedDB = async () => {
}
const columnDefs: ColDef<FeeRow>[] = [
{
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',

View File

@ -1,14 +1,55 @@
<script setup lang="ts">
import { serviceList } from '@/sql'
import { computed, onActivated, onMounted, ref } from 'vue'
import localforage from 'localforage'
import { getServiceDictEntries, isIndustryEnabledByType, getIndustryTypeValue } from '@/sql'
import XmFactorGrid from '@/components/common/XmFactorGrid.vue'
interface XmBaseInfoState {
projectIndustry?: string
}
type ServiceItem = {
code: string
name: string
defCoe: number | null
desc?: string | null
notshowByzxflxs?: boolean
}
const PROJECT_INFO_KEY = 'xm-base-info-v1'
const projectIndustry = ref('')
const loadProjectIndustry = async () => {
try {
const data = await localforage.getItem<XmBaseInfoState>(PROJECT_INFO_KEY)
projectIndustry.value =
typeof data?.projectIndustry === 'string' ? data.projectIndustry.trim() : ''
} catch (error) {
console.error('loadProjectIndustry failed:', error)
projectIndustry.value = ''
}
}
const filteredServiceDict = computed<Record<string, ServiceItem>>(() => {
const industry = projectIndustry.value
if (!industry) return {}
const entries = getServiceDictEntries()
.filter(({ item }) => isIndustryEnabledByType(item, getIndustryTypeValue(industry))
)
.map(({ id, item }) => [id, item as ServiceItem] as const)
return Object.fromEntries(entries)
})
onMounted(() => {
void loadProjectIndustry()
})
onActivated(() => {
void loadProjectIndustry()
})
</script>
<template>
<XmFactorGrid
title="咨询分类系数明细"
storage-key="xm-consult-category-factor-v1"
:dict="serviceList"
:disable-budget-edit-when-standard-null="true"
:exclude-notshow-by-zxflxs="true"
/>
<XmFactorGrid title="咨询分类系数明细" storage-key="xm-consult-category-factor-v1" :dict="filteredServiceDict"
:disable-budget-edit-when-standard-null="true" :exclude-notshow-by-zxflxs="true" />
</template>

View File

@ -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"
/>
</template>
<script setup lang="ts">
import { defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
import { computed, defineAsyncComponent, defineComponent, h, markRaw, type Component } from 'vue'
import TypeLine from '@/layout/typeLine.vue'
import MethodUnavailableNotice from '@/components/common/MethodUnavailableNotice.vue'
interface ServiceMethodType {
scale?: boolean | null
onlyCostScale?: boolean | null
amount?: boolean | null
workDay?: boolean | null
}
const props = defineProps<{
contractId: string
contractName?: string
serviceId: string|number
fwName:string
type?: ServiceMethodType
}>()
interface PricingCategoryItem {
@ -27,6 +36,22 @@ interface PricingCategoryItem {
component: Component
}
const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) =>
typeof value === 'boolean' ? value : fallback
const methodAvailability = computed(() => {
const scale = resolveMethodEnabled(props.type?.scale, true)
const onlyCostScale = resolveMethodEnabled(props.type?.onlyCostScale, false)
const amount = resolveMethodEnabled(props.type?.amount, true)
const workDay = resolveMethodEnabled(props.type?.workDay, true)
return {
investmentScale: scale,
landScale: scale && !onlyCostScale,
workload: amount,
hourly: workDay
}
})
const createPricingPane = (name: string) =>
markRaw(
defineComponent({
@ -44,15 +69,66 @@ const createPricingPane = (name: string) =>
})
)
const createMethodUnavailablePane = (title: string, message: string) =>
markRaw(
defineComponent({
name: 'MethodUnavailablePane',
setup() {
return () => h(MethodUnavailableNotice, { title, message })
}
})
)
const investmentScaleView = createPricingPane('InvestmentScalePricingPane')
const landScaleView = createPricingPane('LandScalePricingPane')
const workloadView = createPricingPane('WorkloadPricingPane')
const hourlyView = createPricingPane('HourlyPricingPane')
const pricingCategories: PricingCategoryItem[] = [
{ key: 'investment-scale-method', label: '投资规模法', component: investmentScaleView },
{ key: 'land-scale-method', label: '用地规模法', component: landScaleView },
{ key: 'workload-method', label: '工作量法', component: workloadView },
{ key: 'hourly-method', label: '工时法', component: hourlyView }
]
const investmentScaleUnavailableView = createMethodUnavailablePane(
'该服务不适用投资规模法',
'当前服务未启用规模法,投资规模法不可编辑。'
)
const landScaleUnavailableView = createMethodUnavailablePane(
'该服务不适用用地规模法',
'当前服务仅支持投资规模法,用地规模法不可编辑。'
)
const workloadUnavailableView = createMethodUnavailablePane(
'该服务不适用工作量法',
'当前服务未启用工作量法,工作量法不可编辑。'
)
const hourlyUnavailableView = createMethodUnavailablePane(
'该服务不适用工时法',
'当前服务未启用工时法,工时法不可编辑。'
)
const pricingCategories = computed<PricingCategoryItem[]>(() => [
{
key: 'investment-scale-method',
label: '投资规模法',
component: methodAvailability.value.investmentScale ? investmentScaleView : investmentScaleUnavailableView
},
{
key: 'land-scale-method',
label: '用地规模法',
component: methodAvailability.value.landScale ? landScaleView : landScaleUnavailableView
},
{
key: 'workload-method',
label: '工作量法',
component: methodAvailability.value.workload ? workloadView : workloadUnavailableView
},
{
key: 'hourly-method',
label: '工时法',
component: methodAvailability.value.hourly ? hourlyView : hourlyUnavailableView
}
])
const defaultCategory = computed(() => {
if (methodAvailability.value.investmentScale) return 'investment-scale-method'
if (methodAvailability.value.landScale) return 'land-scale-method'
if (methodAvailability.value.workload) return 'workload-method'
if (methodAvailability.value.hourly) return 'hourly-method'
return 'investment-scale-method'
})
</script>

View File

@ -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 },
];
</script>

View File

@ -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, industryTypeList, 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,26 @@ const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
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 totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资'
})
const loadFactorDefaults = async () => {
const [consultMap, majorMap] = await Promise.all([
@ -149,7 +168,14 @@ const shouldForceDefaultLoad = () => {
}
const detailRows = ref<DetailRow[]>([])
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
type majorLite = {
code: string
name: string
defCoe: number | null
hasCost?: boolean
hasArea?: boolean
industryId?: string | number | null
}
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
@ -184,12 +210,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 +272,63 @@ const buildDefaultRows = (): DetailRow[] => {
return rows
}
const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) =>
sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null))
const getOnlyCostScaleMajorFactorDefault = () => {
const industryId = String(activeIndustryCode.value || '').trim()
if (!industryId) return 1
const industryMajor = serviceEntries.find(([, item]) => {
const majorIndustryId = String(item?.industryId ?? '').trim()
return majorIndustryId === industryId && !String(item?.code || '').includes('-')
})
if (!industryMajor) return 1
const [majorId, majorItem] = industryMajor
const fromMap = majorFactorMap.value.get(String(majorId))
if (typeof fromMap === 'number' && Number.isFinite(fromMap)) return fromMap
if (typeof majorItem?.defCoe === 'number' && Number.isFinite(majorItem.defCoe)) return majorItem.defCoe
return 1
}
const buildOnlyCostScaleRow = (
amount: number | null,
fromDb?: Partial<Pick<DetailRow, 'consultCategoryFactor' | 'majorFactor' | 'workStageFactor' | 'workRatio' | 'remark'>>
): 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 : getOnlyCostScaleMajorFactorDefault(),
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<Partial<DetailRow> & Pick<DetailRow, 'id'>>
): 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<DetailRow, 'id'> &
Partial<
Pick<
@ -340,6 +428,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,16 +525,22 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
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 }),
valueFormatter: formatEditableMoney
},
@ -457,10 +556,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudgetBasic ?? null
? null
: getBenchmarkBudgetSplitByAmount(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
valueFormatter: formatReadonlyMoney
@ -473,10 +571,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudgetOptional ?? null
? null
: getBenchmarkBudgetSplitByAmount(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
valueFormatter: formatReadonlyMoney
@ -489,10 +586,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudget ?? null
? null
: getBenchmarkBudgetSplitByAmount(params.data)?.total ?? null,
valueFormatter: formatReadonlyMoney
}
@ -508,11 +604,21 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'consultCategoryFactor',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
editable: params =>
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned
? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
isOnlyCostScaleService.value && params.node?.rowPinned
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatConsultCategoryFactor
@ -523,11 +629,21 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'majorFactor',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
editable: params =>
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned
? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
isOnlyCostScaleService.value && params.node?.rowPinned
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatMajorFactor
@ -538,11 +654,21 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'workStageFactor',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
editable: params =>
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned
? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
isOnlyCostScaleService.value && params.node?.rowPinned
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableNumber
@ -553,11 +679,21 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
colId: 'workRatio',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
editable: params =>
isOnlyCostScaleService.value
? Boolean(params.node?.rowPinned)
: !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
isOnlyCostScaleService.value && params.node?.rowPinned
? 'editable-cell-line'
: !params.node?.group && !params.node?.rowPinned
? 'editable-cell-line'
: '',
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
isOnlyCostScaleService.value && params.node?.rowPinned
? params.value == null || params.value === ''
: !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableNumber
@ -613,13 +749,13 @@ const autoGroupColumnDef: ColDef = {
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
return totalLabel.value
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
if (params.node?.rowPinned) return totalLabel.value
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
@ -627,6 +763,8 @@ const autoGroupColumnDef: ColDef = {
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const visibleDetailRows = computed(() => (isOnlyCostScaleService.value ? [] : detailRows.value))
const onlyCostScaleSourceRow = computed(() => detailRows.value[0] ?? buildOnlyCostScaleRow(null))
const totalBenchmarkBudgetBasic = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByAmount(row)?.basic))
const totalBenchmarkBudgetOptional = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByAmount(row)?.optional))
@ -644,18 +782,18 @@ const pinnedTopRowData = computed(() => [
majorName: '',
hasCost: false,
hasArea: false,
amount: totalAmount.value,
benchmarkBudget: totalBenchmarkBudget.value,
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
benchmarkBudgetOptional: totalBenchmarkBudgetOptional.value,
amount: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.amount : null,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '',
optionalFormula: '',
consultCategoryFactor: null,
majorFactor: null,
workStageFactor: null,
workRatio: null,
consultCategoryFactor: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.consultCategoryFactor : null,
majorFactor: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.majorFactor : null,
workStageFactor: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.workStageFactor : null,
workRatio: isOnlyCostScaleService.value ? onlyCostScaleSourceRow.value.workRatio : null,
budgetFee: totalBudgetFee.value,
budgetFeeBasic: totalBudgetFeeBasic.value,
budgetFeeOptional: totalBudgetFeeOptional.value,
@ -727,6 +865,9 @@ 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
if (isOnlyCostScaleService.value) {
detailRows.value = hasContractRows ? buildOnlyCostScaleRows(htData!.detailRows as any) : buildOnlyCostScaleRows()
} else {
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
@ -734,6 +875,7 @@ const loadFromIndexedDB = async () => {
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
}
syncComputedValuesToDetailRows()
}
if (shouldForceDefaultLoad()) {
@ -743,7 +885,9 @@ const loadFromIndexedDB = async () => {
const data = await localforage.getItem<XmInfoState>(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 +895,7 @@ const loadFromIndexedDB = async () => {
await applyContractDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
detailRows.value = isOnlyCostScaleService.value ? buildOnlyCostScaleRows() : buildDefaultRows()
syncComputedValuesToDetailRows()
}
}
@ -766,6 +910,9 @@ 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
if (isOnlyCostScaleService.value) {
detailRows.value = hasContractRows ? buildOnlyCostScaleRows(htData!.detailRows as any) : buildOnlyCostScaleRows()
} else {
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
@ -773,6 +920,7 @@ const importContractData = async () => {
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
}
await saveToIndexedDB()
} catch (error) {
console.error('importContractData failed:', error)
@ -781,7 +929,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 +950,29 @@ let persistTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => {
const applyOnlyCostScalePinnedValue = (field: string, rawValue: unknown) => {
const parsedValue = parseNumberOrNull(rawValue, { precision: 2 })
const current = detailRows.value[0]
if (!current) {
detailRows.value = [buildOnlyCostScaleRow(field === 'amount' ? parsedValue : null)]
return
}
if (
field !== 'amount' &&
field !== 'consultCategoryFactor' &&
field !== 'majorFactor' &&
field !== 'workStageFactor' &&
field !== 'workRatio'
) {
return
}
detailRows.value = [{ ...current, [field]: parsedValue }]
}
const handleCellValueChanged = (event?: any) => {
if (isOnlyCostScaleService.value && event?.node?.rowPinned && typeof event.colDef?.field === 'string') {
applyOnlyCostScalePinnedValue(event.colDef.field, event.newValue)
}
syncComputedValuesToDetailRows()
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
@ -899,7 +1069,7 @@ const processCellFromClipboard = (params: any) => {
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
<AgGridVue :style="{ height: '100%' }" :rowData="visibleDetailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"

View File

@ -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, industryTypeList, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousands } from '@/lib/numberFormat'
@ -96,6 +96,16 @@ const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
const majorFactorMap = ref<Map<string, number | null>>(new Map())
let factorDefaultsLoaded = false
const paneInstanceCreatedAt = Date.now()
const industryNameMap = new Map(
industryTypeList.flatMap(item => [
[String(item.id).trim(), item.name],
[String(item.type).trim(), item.name]
])
)
const totalLabel = computed(() => {
const industryName = industryNameMap.get(activeIndustryCode.value.trim()) || ''
return industryName ? `${industryName}总投资` : '总投资'
})
const detailRows = ref<DetailRow[]>([])
const getDefaultConsultCategoryFactor = () =>
@ -463,10 +473,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudgetBasic ?? null
? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
valueFormatter: formatReadonlyMoney
@ -479,10 +488,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudgetOptional ?? null
? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
valueFormatter: formatReadonlyMoney
@ -495,10 +503,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudget ?? null
? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.total ?? null,
valueFormatter: formatReadonlyMoney
}
@ -616,13 +623,13 @@ const autoGroupColumnDef: ColDef = {
},
valueFormatter: params => {
if (params.node?.rowPinned) {
return '总合计'
return totalLabel.value
}
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return '总合计'
if (params.node?.rowPinned) return totalLabel.value
const nodeId = String(params.value || '')
return idLabelMap.get(nodeId) || nodeId
}
@ -648,11 +655,11 @@ const pinnedTopRowData = computed(() => [
majorName: '',
hasCost: false,
hasArea: false,
amount: totalAmount.value,
amount: null,
landArea: null,
benchmarkBudget: totalBenchmarkBudget.value,
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
benchmarkBudgetOptional: totalBenchmarkBudgetOptional.value,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '',

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
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()
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<ServiceItem[]>(() => {
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
return Boolean(itemCode && item?.name) && item.defCoe !== null && isIndustryEnabledByType(item, getIndustryTypeValue(industry))
})
.map(({ id, item }) => ({
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<DetailRow, 'id' | 'code' | 'name'> = { 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<DetailRow, 'investScale' | 'landScale' | 'workload' | 'hourly'>
) => {
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<XmBaseInfoState>(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<typeof setTimeout> | 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()
})

View File

@ -53,10 +53,14 @@ interface HourlyRow {
interface MajorLite {
code: string
defCoe: number | null
hasCost?: boolean
hasArea?: boolean
industryId?: string | number | null
}
interface ServiceLite {
defCoe: number | null
onlyCostScale?: boolean | null
}
interface TaskLite {
@ -70,6 +74,10 @@ interface ExpertLite {
manageCoe: number | null
}
interface XmBaseInfoState {
projectIndustry?: string
}
export interface PricingMethodTotals {
investScale: number | null
landScale: number | null
@ -77,6 +85,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 +107,11 @@ const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
return toFiniteNumberOrNull(service?.defCoe)
}
const isOnlyCostScaleService = (serviceId: string | number) => {
const service = (getServiceDictById() as Record<string, ServiceLite | undefined>)[String(serviceId)]
return service?.onlyCostScale === true
}
const majorById = new Map(getMajorDictEntries().map(({ id, item }) => [id, item as MajorLite]))
const majorIdAliasMap = getMajorIdAliasMap()
@ -102,6 +121,27 @@ 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 getIndustryMajorEntryByIndustryId = (industryId: string | null | undefined) => {
const key = String(industryId || '').trim()
if (!key) return null
for (const [id, item] of majorById.entries()) {
const majorIndustryId = String(item?.industryId ?? '').trim()
if (majorIndustryId === key && !String(item?.code || '').includes('-')) {
return { id, item }
}
}
return null
}
const resolveFactorValue = (
row: { budgetValue?: number | null; standardFactor?: number | null } | undefined,
fallback: number | null
@ -227,6 +267,38 @@ const getInvestmentBudgetFee = (row: ScaleRow) => {
})
}
const getOnlyCostScaleBudgetFee = (
serviceId: string,
rowsFromDb: Array<Record<string, unknown>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>,
industryId?: string | null
) => {
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 industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
const majorFactor =
toFiniteNumberOrNull(onlyRow?.majorFactor) ??
(industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ??
toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ??
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,30 +445,46 @@ const resolveScaleRows = (
export const getPricingMethodTotalsForService = async (params: {
contractId: string
serviceId: string | number
options?: PricingMethodTotalsOptions
}): Promise<PricingMethodTotals> => {
const serviceId = String(params.serviceId)
const htDbKey = `ht-info-v3-${params.contractId}`
const consultFactorDbKey = `ht-consult-category-factor-v1-${params.contractId}`
const majorFactorDbKey = `ht-major-factor-v1-${params.contractId}`
const baseInfoDbKey = 'xm-base-info-v1'
const investDbKey = `tzGMF-${params.contractId}-${serviceId}`
const landDbKey = `ydGMF-${params.contractId}-${serviceId}`
const workloadDbKey = `gzlF-${params.contractId}-${serviceId}`
const hourlyDbKey = `hourlyPricing-${params.contractId}-${serviceId}`
const [investData, landData, workloadData, hourlyData, htData, consultFactorData, majorFactorData] = await Promise.all([
const [investData, landData, workloadData, hourlyData, htData, consultFactorData, majorFactorData, baseInfo] = await Promise.all([
localforage.getItem<StoredDetailRowsState>(investDbKey),
localforage.getItem<StoredDetailRowsState>(landDbKey),
localforage.getItem<StoredDetailRowsState>(workloadDbKey),
localforage.getItem<StoredDetailRowsState>(hourlyDbKey),
localforage.getItem<StoredDetailRowsState>(htDbKey),
localforage.getItem<StoredFactorState>(consultFactorDbKey),
localforage.getItem<StoredFactorState>(majorFactorDbKey)
localforage.getItem<StoredFactorState>(majorFactorDbKey),
localforage.getItem<XmBaseInfoState>(baseInfoDbKey)
])
const consultCategoryFactorMap = buildConsultCategoryFactorMap(consultFactorData)
const majorFactorMap = buildMajorFactorMap(majorFactorData)
const onlyCostScale = isOnlyCostScaleService(serviceId)
const industryId = typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
// 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。
const excludeInvestmentCostAndAreaRows = params.options?.excludeInvestmentCostAndAreaRows === true
const investScale = onlyCostScale
? getOnlyCostScaleBudgetFee(
serviceId,
(investData?.detailRows as Array<Record<string, unknown>> | undefined) ||
(htData?.detailRows as Array<Record<string, unknown>> | undefined),
consultCategoryFactorMap,
majorFactorMap,
industryId
)
: (() => {
const investRows = resolveScaleRows(
serviceId,
investData,
@ -404,7 +492,11 @@ export const getPricingMethodTotalsForService = async (params: {
consultCategoryFactorMap,
majorFactorMap
)
const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row))
return sumByNumber(investRows, row => {
if (excludeInvestmentCostAndAreaRows && isDualScaleMajorById(row.id)) return null
return getInvestmentBudgetFee(row)
})
})()
const landRows = resolveScaleRows(
serviceId,
@ -443,13 +535,15 @@ export const getPricingMethodTotalsForService = async (params: {
export const getPricingMethodTotalsForServices = async (params: {
contractId: string
serviceIds: Array<string | number>
options?: PricingMethodTotalsOptions
}) => {
const result = new Map<string, PricingMethodTotals>()
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)
})

View File

@ -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<string, any>
@ -314,12 +316,6 @@ export const getMajorDictEntries = (): DictEntry[] => buildDictEntries(majorList
*/
export const getServiceDictEntries = (): DictEntry[] => buildDictEntries(serviceList as Record<string, any>)
/**
* isRoad/isRailway/isWaterway
* @returns
*/
export const isDictItemInIndustryScope = (item: DictItem | undefined, industryCode: unknown): boolean =>
isIndustryEnabledByType(item, getIndustryTypeValue(industryCode))
/**
* ID ->