JGJS2026/src/components/pricing/LandScalePricingPane.vue
2026-03-17 16:24:25 +08:00

1209 lines
44 KiB
Vue

<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef } from 'ag-grid-community'
import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { addNumbers, decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
NumberFieldDecrement,
NumberFieldIncrement,
NumberFieldInput,
NumberFieldRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from 'reka-ui'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
interface DictLeaf {
id: string
code: string
name: string
hasCost: boolean
hasArea: boolean
}
interface DictGroup {
id: string
code: string
name: string
children: DictLeaf[]
}
interface DetailRow {
id: string
projectIndex?: number
majorDictId?: string
groupCode: string
groupName: string
majorCode: string
majorName: string
hasCost: boolean
hasArea: boolean
amount: number | null
landArea: number | null
benchmarkBudget: number | null
benchmarkBudgetBasic: number | null
benchmarkBudgetOptional: number | null
benchmarkBudgetBasicChecked: boolean
benchmarkBudgetOptionalChecked: boolean
basicFormula: string | null
optionalFormula: string | null
consultCategoryFactor: number | null
majorFactor: number | null
workStageFactor: number | null
workRatio: number | null
budgetFee: number | null
budgetFeeBasic: number | null
budgetFeeOptional: number | null
remark: string
path: string[]
}
interface XmInfoState {
projectName: string
detailRows: DetailRow[]
}
interface XmBaseInfoState {
projectIndustry?: string
}
interface ServiceLite {
mutiple?: boolean | null
}
const props = defineProps<{
contractId: string,
serviceId: string | number
projectInfoKey?: string
}>()
const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
const BASE_INFO_KEY = computed(() => props.projectInfoKey || 'xm-base-info-v1')
const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
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 isMutipleService = computed(() => {
const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined
return service?.mutiple === true
})
const projectCount = ref<number>(1)
const PROJECT_PATH_PREFIX = 'project-'
const PROJECT_ROW_ID_SEPARATOR = '::'
const normalizeProjectCount = (value: unknown) => {
const parsed = Number(value)
if (!Number.isFinite(parsed)) return 1
return Math.max(1, Math.floor(parsed))
}
const getTargetProjectCount = () => (isMutipleService.value ? normalizeProjectCount(projectCount.value) : 1)
const buildProjectGroupPathKey = (projectIndex: number) => `${PROJECT_PATH_PREFIX}${projectIndex}`
const parseProjectIndexFromPathKey = (value: string) => {
const match = /^project-(\d+)$/.exec(value)
if (!match) return null
return normalizeProjectCount(Number(match[1]))
}
const buildScopedRowId = (projectIndex: number, majorId: string) =>
isMutipleService.value ? `${projectIndex}${PROJECT_ROW_ID_SEPARATOR}${majorId}` : majorId
const parseScopedRowId = (id: unknown) => {
const rawId = String(id || '')
const match = /^(\d+)::(.+)$/.exec(rawId)
if (!match) {
return {
projectIndex: 1,
majorDictId: rawId
}
}
return {
projectIndex: normalizeProjectCount(Number(match[1])),
majorDictId: String(match[2] || '').trim()
}
}
const resolveRowProjectIndex = (row: Partial<DetailRow> | undefined) => {
if (!row) return 1
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
return normalizeProjectCount(row.projectIndex)
}
if (Array.isArray(row.path) && row.path.length > 0) {
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
if (projectIndexFromPath != null) return projectIndexFromPath
}
return parseScopedRowId(row.id).projectIndex
}
const resolveRowMajorDictId = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const direct = String(row.majorDictId || '').trim()
if (direct) return majorIdAliasMap.get(direct) || direct
const parsed = parseScopedRowId(row.id).majorDictId
return majorIdAliasMap.get(parsed) || parsed
}
const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
const inferProjectCountFromRows = (rows?: Array<Partial<DetailRow>>) => {
if (!isMutipleService.value) return 1
let maxProjectIndex = 1
for (const row of rows || []) {
maxProjectIndex = Math.max(maxProjectIndex, resolveRowProjectIndex(row))
}
return maxProjectIndex
}
const getMethodState = () =>
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'landScale')
const detailRows = computed<DetailRow[]>({
get: () => {
const rows = getMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
const currentState = getMethodState()
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'landScale', {
detailRows: rows,
projectCount: currentState?.projectCount ?? getTargetProjectCount()
})
}
})
const getDefaultConsultCategoryFactor = () =>
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
const loadFactorDefaults = async () => {
const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value),
loadMajorFactorMap(HT_MAJOR_FACTOR_KEY.value)
])
consultCategoryFactorMap.value = consultMap
majorFactorMap.value = majorMap
factorDefaultsLoaded = true
}
const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return
await loadFactorDefaults()
}
const shouldForceDefaultLoad = () => {
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const forceUntil = Number(raw)
sessionStorage.removeItem(storageKey)
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
}
const shouldSkipPersist = () => {
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
const raw = sessionStorage.getItem(storageKey)
if (!raw) return false
const now = Date.now()
if (raw.includes(':')) {
const [issuedRaw, untilRaw] = raw.split(':')
const issuedAt = Number(issuedRaw)
const skipUntil = Number(untilRaw)
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
return paneInstanceCreatedAt <= issuedAt
}
sessionStorage.removeItem(storageKey)
return false
}
const skipUntil = Number(raw)
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
sessionStorage.removeItem(storageKey)
return false
}
type majorLite = { code: string; name: string; defCoe: number | null; hasCost?: boolean; hasArea?: boolean }
const serviceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
const detailDict: DictGroup[] = (() => {
const groupMap = new Map<string, DictGroup>()
const groupOrder: string[] = []
const codeLookup = new Map(serviceEntries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
for (const [key, item] of serviceEntries) {
const code = item.code
const isGroup = !code.includes('-')
if (isGroup) {
if (!groupMap.has(code)) groupOrder.push(code)
groupMap.set(code, {
id: key,
code,
name: item.name,
children: []
})
continue
}
const parentCode = code.split('-')[0]
if (!groupMap.has(parentCode)) {
const parent = codeLookup.get(parentCode)
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
groupMap.set(parentCode, {
id: parent?.id || `group-${parentCode}`,
code: parentCode,
name: parent?.name || parentCode,
children: []
})
}
const hasArea = item.hasArea !== false
if (!hasArea) continue
groupMap.get(parentCode)!.children.push({
id: key,
code,
name: item.name,
hasCost: item.hasCost !== false,
hasArea
})
}
return groupOrder.map(code => groupMap.get(code)).filter((group): group is DictGroup => Boolean(group))
})()
const idLabelMap = new Map<string, string>()
for (const group of detailDict) {
idLabelMap.set(group.id, `${group.code} ${group.name}`)
for (const child of group.children) {
idLabelMap.set(child.id, `${child.code} ${child.name}`)
}
}
const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => {
if (!activeIndustryCode.value) return []
const rows: DetailRow[] = []
for (let projectIndex = 1; projectIndex <= projectCountValue; projectIndex++) {
for (const group of detailDict) {
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
for (const child of group.children) {
const rowId = buildScopedRowId(projectIndex, child.id)
rows.push({
id: rowId,
projectIndex,
majorDictId: child.id,
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name,
hasCost: child.hasCost,
hasArea: child.hasArea,
amount: null,
landArea: null,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '',
optionalFormula: '',
consultCategoryFactor: null,
majorFactor: null,
workStageFactor: 1,
workRatio: 100,
budgetFee: null,
budgetFeeBasic: null,
budgetFeeOptional: null,
remark: '',
path: isMutipleService.value
? [buildProjectGroupPathKey(projectIndex), group.id, rowId]
: [group.id, rowId]
})
}
}
}
return rows
}
type SourceRow = Pick<DetailRow, 'id'> &
Partial<
Pick<
DetailRow,
| 'projectIndex'
| 'majorDictId'
| 'amount'
| 'landArea'
| 'benchmarkBudget'
| 'benchmarkBudgetBasic'
| 'benchmarkBudgetOptional'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'basicFormula'
| 'optionalFormula'
| 'consultCategoryFactor'
| 'majorFactor'
| 'workStageFactor'
| 'workRatio'
| 'budgetFee'
| 'budgetFeeBasic'
| 'budgetFeeOptional'
| 'remark'
>
>
const mergeWithDictRows = (
rowsFromDb: SourceRow[] | undefined,
options?: {
includeScaleValues?: boolean
includeFactorValues?: boolean
projectCount?: number
cloneFromProjectOne?: boolean
}
): DetailRow[] => {
const includeScaleValues = options?.includeScaleValues ?? true
const includeFactorValues = options?.includeFactorValues ?? true
const targetProjectCount = normalizeProjectCount(options?.projectCount ?? getTargetProjectCount())
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
const projectIndex = resolveRowProjectIndex(row)
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) continue
dbValueMap.set(makeProjectMajorKey(projectIndex, majorDictId), row)
}
return buildDefaultRows(targetProjectCount).map(row => {
const rowProjectIndex = resolveRowProjectIndex(row)
const rowMajorDictId = resolveRowMajorDictId(row)
const fromDb =
dbValueMap.get(makeProjectMajorKey(rowProjectIndex, rowMajorDictId)) ||
(options?.cloneFromProjectOne && rowProjectIndex > 1
? dbValueMap.get(makeProjectMajorKey(1, rowMajorDictId))
: undefined)
if (!fromDb) return row
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
const hasMajorFactor = Object.prototype.hasOwnProperty.call(fromDb, 'majorFactor')
return {
...row,
amount: includeScaleValues && row.hasCost && typeof fromDb.amount === 'number' ? fromDb.amount : null,
landArea: includeScaleValues && row.hasArea && typeof fromDb.landArea === 'number' ? fromDb.landArea : null,
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : null,
benchmarkBudgetBasicChecked:
typeof fromDb.benchmarkBudgetBasicChecked === 'boolean' ? fromDb.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked:
typeof fromDb.benchmarkBudgetOptionalChecked === 'boolean' ? fromDb.benchmarkBudgetOptionalChecked : true,
basicFormula: typeof fromDb.basicFormula === 'string' ? fromDb.basicFormula : '',
optionalFormula: typeof fromDb.optionalFormula === 'string' ? fromDb.optionalFormula : '',
consultCategoryFactor:
!includeFactorValues
? null
: typeof fromDb.consultCategoryFactor === 'number'
? fromDb.consultCategoryFactor
: hasConsultCategoryFactor
? null
: getDefaultConsultCategoryFactor(),
majorFactor:
!includeFactorValues
? null
: typeof fromDb.majorFactor === 'number'
? fromDb.majorFactor
: hasMajorFactor
? null
: getDefaultMajorFactorById(rowMajorDictId),
workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor,
workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
budgetFeeBasic: typeof fromDb.budgetFeeBasic === 'number' ? fromDb.budgetFeeBasic : null,
budgetFeeOptional: typeof fromDb.budgetFeeOptional === 'number' ? fromDb.budgetFeeOptional : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
}
})
}
const formatEditableNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '请输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
const formatEditableRatio2 = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '请输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 2)
}
const formatConsultCategoryFactor = (params: any) => {
return formatEditableNumber(params)
}
const formatMajorFactor = (params: any) => {
return formatEditableNumber(params)
}
const formatReadonlyMoney = (params: any) => {
if (params.value == null || params.value === '') return ''
return formatThousandsFlexible(roundTo(params.value, 3), 3)
}
type BudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => {
const valueText = formatReadonlyMoney(params)
const hasValue = params.value != null && params.value !== ''
if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
return valueText
}
const wrapper = document.createElement('div')
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'space-between'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', () => {
params.data[checkField] = checkbox.checked
if (!checkbox.checked) {
const budgetField = checkField === 'benchmarkBudgetBasicChecked' ? 'benchmarkBudgetBasic' : 'benchmarkBudgetOptional'
params.data[budgetField] = 0
}
handleCellValueChanged()
})
const valueSpan = document.createElement('span')
valueSpan.textContent = valueText
wrapper.append(checkbox, valueSpan)
return wrapper
}
const getBenchmarkBudgetSplitByLandArea = (row?: Pick<DetailRow, 'landArea'>) =>
getBenchmarkBudgetSplitByScale(row?.landArea, 'area')
const getCheckedBenchmarkBudgetSplitByLandArea = (
row?: Pick<DetailRow, 'landArea' | 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
) => {
const split = getBenchmarkBudgetSplitByLandArea(row)
if (!split) return null
const basic = row?.benchmarkBudgetBasicChecked === false ? 0 : split.basic
const optional = row?.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
return {
...split,
basic,
optional,
total: roundTo(addNumbers(basic, optional), 2)
}
}
const getBudgetFee = (
row?: Pick<
DetailRow,
| 'landArea'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'majorFactor'
| 'consultCategoryFactor'
| 'workStageFactor'
| 'workRatio'
>
) => {
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null
const splitBudgetFee = getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
})
return splitBudgetFee ? splitBudgetFee.total : null
}
const getBudgetFeeSplit = (
row?: Pick<
DetailRow,
| 'landArea'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'majorFactor'
| 'consultCategoryFactor'
| 'workStageFactor'
| 'workRatio'
>
) => {
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null
return getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
})
}
const getMergeColSpanBeforeTotal = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned) return 1
const displayedColumns = params.api?.getAllDisplayedColumns?.()
if (!Array.isArray(displayedColumns) || !params.column) return 1
const currentIndex = displayedColumns.findIndex((column: any) => column.getColId() === params.column.getColId())
const totalIndex = displayedColumns.findIndex((column: any) => column.getColId() === 'budgetFeeTotal')
if (currentIndex < 0 || totalIndex <= currentIndex) return 1
return totalIndex - currentIndex
}
const formatEditableFlexibleNumber = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && !params.data?.hasArea) {
return ''
}
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '点击输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 3)
}
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{
headerName: '用地面积(亩)',
field: 'landArea',
headerClass: 'ag-right-aligned-header',
minWidth: 90,
flex: 2,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasArea
? '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?.hasArea) && (params.value == null || params.value === '')
},
valueParser: params => {
const value = parseNumberOrNull(params.newValue)
return value == null ? null : roundTo(value, 3)
},
valueFormatter: formatEditableFlexibleNumber
},
{
headerName: '基准预算(元)',
marryChildren: true,
children: [
{
headerName: '基本工作',
field: 'benchmarkBudgetBasic',
colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
valueGetter: params =>
params.node?.rowPinned
? null
: getCheckedBenchmarkBudgetSplitByLandArea(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
valueFormatter: formatReadonlyMoney
},
{
headerName: '可选工作',
field: 'benchmarkBudgetOptional',
colId: 'benchmarkBudgetOptional',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
valueGetter: params =>
params.node?.rowPinned
? null
: getCheckedBenchmarkBudgetSplitByLandArea(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
valueFormatter: formatReadonlyMoney
},
{
headerName: '小计',
field: 'benchmarkBudget',
colId: 'benchmarkBudgetTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
valueGetter: params =>
params.node?.rowPinned
? null
: getCheckedBenchmarkBudgetSplitByLandArea(params.data)?.total ?? null,
valueFormatter: formatReadonlyMoney
}
]
},
{
headerName: '预算费用',
marryChildren: true,
children: [
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
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' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatConsultCategoryFactor
},
{
headerName: '专业系数',
field: 'majorFactor',
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' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatMajorFactor
},
{
headerName: '工作环节系数(编审系数)',
field: 'workStageFactor',
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' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber
},
{
headerName: '工作占比(%)',
field: 'workRatio',
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' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableRatio2
},
{
headerName: '合计',
field: 'budgetFee',
colId: 'budgetFeeTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueFormatter: formatReadonlyMoney
}
]
},
{
headerName: '说明',
field: 'remark',
minWidth: 100,
flex: 1.2,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
editable: params => !params.node?.group && !params.node?.rowPinned,
valueFormatter: params => {
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
return params.value || ''
},
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
}
}
]
const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称',
minWidth: 250,
flex: 2,
cellRendererParams: {
suppressCount: true
},
colSpan: getMergeColSpanBeforeTotal,
valueFormatter: params => {
if (params.node?.rowPinned) {
return totalLabel.value
}
const rowData = params.data as DetailRow | undefined
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '')
const projectIndex = parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}`
return idLabelMap.get(nodeId) || nodeId
},
tooltipValueGetter: params => {
if (params.node?.rowPinned) return totalLabel.value
const rowData = params.data as DetailRow | undefined
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
return `${rowData.majorCode} ${rowData.majorName}`
}
const nodeId = String(params.value || '')
const projectIndex = parseProjectIndexFromPathKey(nodeId)
if (projectIndex != null) return `项目${projectIndex}`
return idLabelMap.get(nodeId) || nodeId
}
}
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalBenchmarkBudgetBasic = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.basic))
const totalBenchmarkBudgetOptional = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.optional))
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.total))
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
if (!Number.isFinite(value)) return null
hasValid = true
return value
})
return hasValid ? total : null
}
const totalBudgetFeeBasic = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
const totalBudgetFeeOptional = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
const totalBudgetFee = computed(() =>
sumNullableBy(detailRows.value.filter(e => e.budgetFee !== null && e.budgetFee !== undefined), row => getBudgetFee(row))
)
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
groupCode: '',
groupName: '',
majorCode: '',
majorName: '',
hasCost: false,
hasArea: false,
amount: null,
landArea: null,
benchmarkBudget: null,
benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '',
optionalFormula: '',
consultCategoryFactor: null,
majorFactor: null,
workStageFactor: null,
workRatio: null,
budgetFee: totalBudgetFee.value,
budgetFeeBasic: totalBudgetFeeBasic.value,
budgetFeeOptional: totalBudgetFeeOptional.value,
remark: '',
path: ['TOTAL']
}
])
const syncComputedValuesToDetailRows = () => {
for (const row of detailRows.value) {
const benchmarkBudgetRawSplit = getBenchmarkBudgetSplitByLandArea(row)
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(row)
const budgetFeeSplit = benchmarkBudgetSplit
? getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
})
: null
row.benchmarkBudget = benchmarkBudgetSplit?.total ?? null
row.benchmarkBudgetBasic = benchmarkBudgetSplit?.basic ?? null
row.benchmarkBudgetOptional = benchmarkBudgetSplit?.optional ?? null
row.basicFormula =
row.benchmarkBudgetBasicChecked === false ? null : (benchmarkBudgetRawSplit?.basicFormula ?? '')
row.optionalFormula =
row.benchmarkBudgetOptionalChecked === false ? null : (benchmarkBudgetRawSplit?.optionalFormula ?? '')
row.budgetFee = budgetFeeSplit?.total ?? null
row.budgetFeeBasic = budgetFeeSplit?.basic ?? null
row.budgetFeeOptional = budgetFeeSplit?.optional ?? null
}
}
const buildPersistDetailRows = () => {
syncComputedValuesToDetailRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return
try {
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())),
projectCount: getTargetProjectCount()
}
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'landScale', payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'landScale',
value: totalBudgetFee.value
})
if (!synced) return
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const getProjectMajorKeyFromRow = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) return ''
return makeProjectMajorKey(resolveRowProjectIndex(row), majorDictId)
}
const buildRowsFromImportDefaultSource = async (
targetProjectCount: number
): Promise<DetailRow[]> => {
// 与“使用默认数据”同源:先强制刷新系数,再按合同卡片默认带出。
await loadFactorDefaults()
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
return hasContractRows
? mergeWithDictRows(htData!.detailRows, {
includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
}))
}
const applyProjectCountChange = async (nextValue: unknown) => {
const normalized = normalizeProjectCount(nextValue)
projectCount.value = normalized
if (!isMutipleService.value) return
const previousRows = detailRows.value.map(row => ({ ...row }))
const previousProjectCount = inferProjectCountFromRows(previousRows)
if (normalized === previousProjectCount) return
if (normalized < previousProjectCount) {
detailRows.value = mergeWithDictRows(previousRows as any, { projectCount: normalized })
syncComputedValuesToDetailRows()
await saveToIndexedDB()
return
}
const defaultRows = await buildRowsFromImportDefaultSource(normalized)
const existingMap = new Map<string, DetailRow>()
for (const row of previousRows) {
const key = getProjectMajorKeyFromRow(row)
if (!key) continue
existingMap.set(key, row)
}
detailRows.value = defaultRows.map(defaultRow => {
const key = getProjectMajorKeyFromRow(defaultRow)
const existingRow = key ? existingMap.get(key) : undefined
if (!existingRow) return defaultRow
if (resolveRowProjectIndex(existingRow) > previousProjectCount) return defaultRow
return {
...defaultRow,
...existingRow,
id: defaultRow.id,
projectIndex: defaultRow.projectIndex,
majorDictId: defaultRow.majorDictId,
groupCode: defaultRow.groupCode,
groupName: defaultRow.groupName,
majorCode: defaultRow.majorCode,
majorName: defaultRow.majorName,
hasCost: defaultRow.hasCost,
hasArea: defaultRow.hasArea,
path: defaultRow.path
}
})
syncComputedValuesToDetailRows()
await saveToIndexedDB()
}
const loadFromIndexedDB = async () => {
try {
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
projectCount.value = 1
await ensureFactorDefaultsLoaded()
const applyContractDefaultRows = async () => {
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
const targetProjectCount = getTargetProjectCount()
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, {
includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
}))
syncComputedValuesToDetailRows()
}
if (shouldForceDefaultLoad()) {
await applyContractDefaultRows()
return
}
const data = await zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'landScale')
if (data) {
if (isMutipleService.value) {
const storedProjectCount = normalizeProjectCount(data.projectCount)
projectCount.value = storedProjectCount || inferProjectCountFromRows(data.detailRows as any)
}
detailRows.value = mergeWithDictRows(data.detailRows as any, {
projectCount: getTargetProjectCount(),
cloneFromProjectOne: true
})
syncComputedValuesToDetailRows()
return
}
await applyContractDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows(getTargetProjectCount())
syncComputedValuesToDetailRows()
}
}
const importContractData = async () => {
try {
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
activeIndustryCode.value =
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
// 使用默认数据时,强制读取最新的项目系数(预算取值优先,空值回退标准系数)
await loadFactorDefaults()
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
const targetProjectCount = getTargetProjectCount()
detailRows.value = hasContractRows
? mergeWithDictRows(htData!.detailRows, {
includeFactorValues: true,
projectCount: targetProjectCount,
cloneFromProjectOne: true
})
: buildDefaultRows(targetProjectCount).map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.majorDictId || row.id)
}))
await saveToIndexedDB()
} catch (error) {
console.error('importContractData failed:', error)
}
}
const clearAllData = async () => {
try {
detailRows.value = buildDefaultRows(getTargetProjectCount())
await saveToIndexedDB()
} catch (error) {
console.error('clearAllData failed:', error)
}
}
const handleCellValueChanged = () => {
syncComputedValuesToDetailRows()
void saveToIndexedDB()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
onBeforeUnmount(() => {
void saveToIndexedDB()
})
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params: any) => {
try {
const parsed = JSON.parse(params.value);
if (Array.isArray(parsed)) return parsed;
} catch (e) {
// 解析失败时返回原始值,无需额外处理
}
return params.value;
};
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<div class="flex items-center gap-3">
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
<div v-if="isMutipleService" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">项目数量</span>
<NumberFieldRoot
v-model="projectCount"
:min="1"
:step="1"
class="inline-flex items-center rounded-md border bg-background"
@update:model-value="value => void applyProjectCountChange(value)"
>
<NumberFieldDecrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-</NumberFieldDecrement>
<NumberFieldInput class="h-7 w-14 border-x bg-transparent px-2 text-center text-xs outline-none" />
<NumberFieldIncrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+</NumberFieldIncrement>
</NumberFieldRoot>
</div>
</div>
<div class="flex items-center gap-2">
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">清空</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空当前用地规模明细,是否继续?
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="clearAllData">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认覆盖当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用合同默认数据覆盖当前用地规模明细,是否继续?
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button @click="importContractData">确认覆盖</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</div>
<div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
</div>
</div>
</div>
</template>