926 lines
33 KiB
Vue
926 lines
33 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { AgGridVue } from 'ag-grid-vue3'
|
||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||
import localforage from 'localforage'
|
||
import { getMajorDictEntries, industryTypeList, isMajorIdInIndustryScope } from '@/sql'
|
||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||
import { formatThousands } from '@/lib/numberFormat'
|
||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||
import { parseNumberOrNull } from '@/lib/number'
|
||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||
import { Button } from '@/components/ui/button'
|
||
import {
|
||
AlertDialogAction,
|
||
AlertDialogCancel,
|
||
AlertDialogContent,
|
||
AlertDialogDescription,
|
||
AlertDialogOverlay,
|
||
AlertDialogPortal,
|
||
AlertDialogRoot,
|
||
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
|
||
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
|
||
optionalFormula: string
|
||
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
|
||
}
|
||
|
||
const props = defineProps<{
|
||
contractId: string,
|
||
serviceId: string | number
|
||
}>()
|
||
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 = 'xm-base-info-v1'
|
||
const activeIndustryCode = ref('')
|
||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||
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 = () =>
|
||
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: []
|
||
})
|
||
}
|
||
|
||
groupMap.get(parentCode)!.children.push({
|
||
id: key,
|
||
code,
|
||
name: item.name,
|
||
hasCost: item.hasCost !== false,
|
||
hasArea: item.hasArea !== false
|
||
})
|
||
}
|
||
|
||
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 = (): DetailRow[] => {
|
||
if (!activeIndustryCode.value) return []
|
||
const rows: DetailRow[] = []
|
||
for (const group of detailDict) {
|
||
if (activeIndustryCode.value && !isMajorIdInIndustryScope(group.id, activeIndustryCode.value)) continue
|
||
for (const child of group.children) {
|
||
rows.push({
|
||
id: 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: [group.id, child.id]
|
||
})
|
||
}
|
||
}
|
||
return rows
|
||
}
|
||
|
||
type SourceRow = Pick<DetailRow, 'id'> &
|
||
Partial<
|
||
Pick<
|
||
DetailRow,
|
||
| '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 }
|
||
): DetailRow[] => {
|
||
const includeScaleValues = options?.includeScaleValues ?? true
|
||
const includeFactorValues = options?.includeFactorValues ?? true
|
||
const dbValueMap = new Map<string, SourceRow>()
|
||
for (const row of rowsFromDb || []) {
|
||
const rowId = String(row.id)
|
||
dbValueMap.set(rowId, row)
|
||
const aliasId = majorIdAliasMap.get(rowId)
|
||
if (aliasId && !dbValueMap.has(aliasId)) {
|
||
dbValueMap.set(aliasId, row)
|
||
}
|
||
}
|
||
|
||
return buildDefaultRows().map(row => {
|
||
const fromDb = dbValueMap.get(row.id)
|
||
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(row.id),
|
||
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 Number(params.value).toFixed(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 formatThousands(roundTo(params.value, 2))
|
||
}
|
||
|
||
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 = 'flex-end'
|
||
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
|
||
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 getBudgetFee = (
|
||
row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
|
||
) => {
|
||
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(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' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
|
||
) => {
|
||
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(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 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 formatThousands(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
|
||
: getBenchmarkBudgetSplitByLandArea(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
|
||
: getBenchmarkBudgetSplitByLandArea(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
|
||
: getBenchmarkBudgetSplitByLandArea(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: 2 }),
|
||
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: 2 }),
|
||
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: 2 }),
|
||
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: formatEditableNumber
|
||
},
|
||
{
|
||
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,
|
||
pinned: 'left',
|
||
flex: 2,
|
||
|
||
cellRendererParams: {
|
||
suppressCount: true
|
||
},
|
||
valueFormatter: params => {
|
||
if (params.node?.rowPinned) {
|
||
return totalLabel.value
|
||
}
|
||
const nodeId = String(params.value || '')
|
||
return idLabelMap.get(nodeId) || nodeId
|
||
},
|
||
tooltipValueGetter: params => {
|
||
if (params.node?.rowPinned) return totalLabel.value
|
||
const nodeId = String(params.value || '')
|
||
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 totalBudgetFeeBasic = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
|
||
const totalBudgetFeeOptional = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
|
||
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, 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 benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(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 = benchmarkBudgetSplit?.basicFormula ?? ''
|
||
row.optionalFormula = benchmarkBudgetSplit?.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()))
|
||
}
|
||
console.log('Saving to IndexedDB:', payload)
|
||
await localforage.setItem(DB_KEY.value, payload)
|
||
const synced = await syncPricingTotalToZxFw({
|
||
contractId: props.contractId,
|
||
serviceId: props.serviceId,
|
||
field: 'landScale',
|
||
value: totalBudgetFee.value
|
||
})
|
||
if (synced) {
|
||
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||
}
|
||
} catch (error) {
|
||
console.error('saveToIndexedDB failed:', error)
|
||
}
|
||
}
|
||
|
||
const loadFromIndexedDB = async () => {
|
||
try {
|
||
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||
activeIndustryCode.value =
|
||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||
|
||
await ensureFactorDefaultsLoaded()
|
||
const applyContractDefaultRows = async () => {
|
||
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
|
||
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
|
||
detailRows.value = hasContractRows
|
||
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
|
||
: buildDefaultRows().map(row => ({
|
||
...row,
|
||
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
||
majorFactor: getDefaultMajorFactorById(row.id)
|
||
}))
|
||
syncComputedValuesToDetailRows()
|
||
}
|
||
if (shouldForceDefaultLoad()) {
|
||
await applyContractDefaultRows()
|
||
return
|
||
}
|
||
|
||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||
if (data) {
|
||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||
syncComputedValuesToDetailRows()
|
||
return
|
||
}
|
||
|
||
await applyContractDefaultRows()
|
||
} catch (error) {
|
||
console.error('loadFromIndexedDB failed:', error)
|
||
detailRows.value = buildDefaultRows()
|
||
syncComputedValuesToDetailRows()
|
||
}
|
||
}
|
||
|
||
const importContractData = async () => {
|
||
try {
|
||
const baseInfo = await localforage.getItem<XmBaseInfoState>(BASE_INFO_KEY)
|
||
activeIndustryCode.value =
|
||
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
|
||
|
||
// 使用默认数据时,强制读取最新的项目系数(预算取值优先,空值回退标准系数)
|
||
await loadFactorDefaults()
|
||
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
|
||
const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
|
||
detailRows.value = hasContractRows
|
||
? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
|
||
: buildDefaultRows().map(row => ({
|
||
...row,
|
||
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
||
majorFactor: getDefaultMajorFactorById(row.id)
|
||
}))
|
||
await saveToIndexedDB()
|
||
} catch (error) {
|
||
console.error('importContractData failed:', error)
|
||
}
|
||
}
|
||
|
||
const clearAllData = async () => {
|
||
try {
|
||
detailRows.value = buildDefaultRows()
|
||
await saveToIndexedDB()
|
||
} catch (error) {
|
||
console.error('clearAllData failed:', error)
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
|
||
(nextVersion, prevVersion) => {
|
||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||
void loadFromIndexedDB()
|
||
}
|
||
)
|
||
|
||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
|
||
|
||
|
||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||
const handleCellValueChanged = () => {
|
||
syncComputedValuesToDetailRows()
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
gridPersistTimer = setTimeout(() => {
|
||
void saveToIndexedDB()
|
||
}, 300)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadFromIndexedDB()
|
||
})
|
||
|
||
onActivated(() => {
|
||
void loadFromIndexedDB()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (persistTimer) clearTimeout(persistTimer)
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
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">
|
||
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
|
||
<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="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||
<AgGridVue :style="{ height: '100%' }" :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>
|
||
|
||
|