JGJS2026/src/features/pricing/components/LandScalePricingPane.vue
2026-04-09 09:15:49 +08:00

1109 lines
40 KiB
Vue

<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { getIndustryDisplayName, getMajorDictEntries, getServiceDictItemById, isMajorIdInIndustryScope } from '@/sql'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo } from '@/lib/decimal'
import { formatThousandsFlexible } from '@/lib/numberFormat'
import { withReadonlyAutoHeight } from '@/lib/agGridReadonlyAutoHeight'
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 {
buildScaleDetailDict,
buildScaleIdLabelMap,
buildScaleRowsFromDict,
type ScaleDictGroup
} from '@/lib/pricingScaleDict'
import {
createScaleAutoGroupColumn,
createScaleBenchmarkBudgetColumnGroup,
createScaleBudgetFeeColumnGroup,
createScaleRemarkColumn,
createScaleValueColumn
} from '@/lib/pricingScaleColumns'
import {
createScaleBudgetCellRendererToggleFactory,
formatScaleEditableConditionalNumber,
restoreScaleColumnDefaults,
type ScaleBudgetCheckField,
type ScaleBudgetHeaderCheckState,
ScaleBudgetToggleHeader,
} from '@/lib/pricingScaleGrid'
import {
getCheckedBenchmarkBudgetSplitByRow,
getScaleBudgetFeeByRow,
getScaleBudgetFeeSplitByRow,
isBenchmarkBudgetFullyUnchecked,
isSameNullableNumber,
recomputeScaleDetailRowsInPlace
} from '@/lib/pricingScaleDetail'
import { sumNullableBy } from '@/lib/pricingScaleCalc'
import { createPinnedTopRowData, createScalePinnedTotalRow } from '@/lib/pricingPinnedRows'
import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
import {
applyPricingScaleProjectCountChange,
clearPricingScalePaneRows,
importPricingScalePaneRows,
loadPricingScalePaneRows
} from '@/lib/pricingScalePaneData'
import {
buildScaleProjectGroupPathKey,
buildScopedScaleRowId,
getScaleProjectMajorKeyFromRow,
inferScaleProjectCountFromRows,
normalizeScaleProjectCount
} from '@/lib/pricingScaleProject'
import { mergeScaleRowsFromProjectMajorMap } from '@/lib/pricingScaleRowMap'
import { buildProjectScopedSessionKey } from '@/lib/pricingPersistControl'
import {
buildContractScaleIdMap,
buildContractScaleMap,
buildContractScaleProjectTotals,
getContractScaleRowByMajor,
getContractScaleProjectTotalsByRow,
makeProjectMajorKey,
normalizeChangedScaleRowIds,
parseProjectIndexFromPathKey,
parseScopedRowId,
resolveScaleRowMajorDictId as resolveRowMajorDictId,
resolveScaleRowProjectIndex as resolveRowProjectIndex
} from '@/lib/pricingScaleLink'
import { AgGridResetHeader } from '@/lib/agGridResetHeader'
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 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 ContractScaleChangeState {
changedRowIds?: string[]
updatedAt?: number
}
interface FactorChangeState {
changedRowIds?: string[]
updatedAt?: number
}
interface ServiceLite {
mutiple?: boolean | null
}
const props = defineProps<{
contractId: string,
serviceId: string | number
projectInfoKey?: string
}>()
const zxFwPricingStore = useZxFwPricingStore()
const { t, locale } = useI18n()
const kvStore = useKvStore()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_SCALE_CHANGE_KEY = computed(() => `ht-info-scale-change-v1-${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 HT_CONSULT_FACTOR_CHANGE_KEY = computed(() => `${HT_CONSULT_FACTOR_KEY.value}-change`)
const HT_MAJOR_FACTOR_CHANGE_KEY = computed(() => `${HT_MAJOR_FACTOR_KEY.value}-change`)
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 gridApi = ref<GridApi<DetailRow> | null>(null)
const lastAppliedConsultFactorChangeAt = ref(0)
const lastAppliedMajorFactorChangeAt = ref(0)
const totalLabel = computed(() => {
const industryName = getIndustryDisplayName(activeIndustryCode.value.trim(), locale.value)
return industryName ? t('pricingScale.totalInvestmentByIndustry', { industryName }) : t('pricingScale.totalInvestment')
})
const isMutipleService = computed(() => {
const service = getServiceDictItemById(props.serviceId) as ServiceLite | undefined
return service?.mutiple === true
})
const projectCount = ref<number>(1)
const normalizeProjectCount = normalizeScaleProjectCount
const getTargetProjectCount = () => (isMutipleService.value ? normalizeProjectCount(projectCount.value) : 1)
const buildProjectGroupPathKey = (projectIndex: number) => buildScaleProjectGroupPathKey(projectIndex)
const buildScopedRowId = (projectIndex: number, majorId: string) =>
buildScopedScaleRowId(isMutipleService.value, projectIndex, majorId)
const inferProjectCountFromRows = (rows?: Array<Partial<DetailRow>>) => {
return inferScaleProjectCountFromRows(rows, isMutipleService.value)
}
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 = buildProjectScopedSessionKey(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 = buildProjectScopedSessionKey(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: Array<[string, majorLite]> = []
const detailDict: ScaleDictGroup[] = []
const idLabelMap = new Map<string, string>()
const rebuildScaleDictCaches = () => {
const nextServiceEntries = getMajorDictEntries().map(({ id, item }) => [id, item] as [string, majorLite])
serviceEntries.splice(0, serviceEntries.length, ...nextServiceEntries)
const nextDetailDict = buildScaleDetailDict(
serviceEntries,
({ hasArea }) => hasArea
)
detailDict.splice(0, detailDict.length, ...nextDetailDict)
const nextIdLabelMap = buildScaleIdLabelMap(nextDetailDict)
idLabelMap.clear()
nextIdLabelMap.forEach((label, id) => {
idLabelMap.set(id, label)
})
}
rebuildScaleDictCaches()
const buildDefaultRows = (projectCountValue = getTargetProjectCount()): DetailRow[] => {
return buildScaleRowsFromDict({
detailDict,
projectCount: projectCountValue,
activeIndustryCode: activeIndustryCode.value,
isMajorInIndustryScope: isMajorIdInIndustryScope,
buildScopedRowId,
buildProjectGroupPathKey,
isMutipleService: isMutipleService.value,
createRow: ({ projectIndex, group, child, rowId, path }) => ({
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: 1,
budgetFee: null,
budgetFeeBasic: null,
budgetFeeOptional: null,
remark: '',
path
})
})
}
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())
return mergeScaleRowsFromProjectMajorMap({
rowsFromDb,
projectCount: targetProjectCount,
buildDefaultRows,
resolveProjectIndex: row => resolveRowProjectIndex(row),
resolveMajorDictId: row => resolveRowMajorDictId(row),
cloneFromProjectOne: options?.cloneFromProjectOne,
mergeRow: (row, fromDb) => {
if (!fromDb) return row
const nextMajorDictId = resolveRowMajorDictId(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(nextMajorDictId),
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 createBudgetCellRendererWithCheck = createScaleBudgetCellRendererToggleFactory(
() => detailRows.value,
() => handleCellValueChanged()
)
const getBenchmarkBudgetHeaderCheckState = (field: ScaleBudgetCheckField): ScaleBudgetHeaderCheckState => {
const targetRows = detailRows.value.filter(row => row.hasArea)
if (!targetRows.length) return 'all'
const checkedCount = targetRows.reduce((count, row) => (row[field] !== false ? count + 1 : count), 0)
if (checkedCount === 0) return 'none'
if (checkedCount === targetRows.length) return 'all'
return 'partial'
}
const toggleBenchmarkBudgetColumnChecked = (field: ScaleBudgetCheckField, checked: boolean) => {
const targetRows = detailRows.value.filter(row => row.hasArea)
if (!targetRows.length) return
for (const row of targetRows) {
row[field] = checked
}
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
handleCellValueChanged()
}
const getBenchmarkBudgetHeaderParams = (field: ScaleBudgetCheckField) => ({
field,
getHeaderCheckState: getBenchmarkBudgetHeaderCheckState,
onToggleAll: toggleBenchmarkBudgetColumnChecked
})
const getBenchmarkBudgetSplitByLandArea = (row?: Pick<DetailRow, 'landArea'>) =>
getCheckedBenchmarkBudgetSplitByRow(
{
landArea: row?.landArea,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true
},
'area'
)
const getCheckedBenchmarkBudgetSplitByLandArea = (
row?: Pick<DetailRow, 'landArea' | 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
) => getCheckedBenchmarkBudgetSplitByRow(row, 'area')
const getBudgetFee = (
row?: Pick<
DetailRow,
| 'landArea'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'majorFactor'
| 'consultCategoryFactor'
| 'workStageFactor'
| 'workRatio'
>
) => getScaleBudgetFeeByRow(row, 'area')
const getBudgetFeeSplit = (
row?: Pick<
DetailRow,
| 'landArea'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'majorFactor'
| 'consultCategoryFactor'
| 'workStageFactor'
| 'workRatio'
>
) => getScaleBudgetFeeSplitByRow(row, 'area')
const formatEditableFlexibleNumber = (params: any) =>
formatScaleEditableConditionalNumber(params, {
enabled: Boolean(params.data?.hasArea),
precision: 3,
emptyText: t('pricingScale.clickToInput')
})
const restoreLandAreaColumnDefaults = async () => {
const htData = await kvStore.getItem<{ detailRows: SourceRow[]; totalAmount?: number | null }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount)
const useSummaryLandArea = detailRows.value.length === 1
await restoreScaleColumnDefaults({
gridApi: gridApi.value,
rows: detailRows.value,
getCurrentValue: row => row.landArea,
getNextValue: row => {
if (!row.hasArea) return null
if (useSummaryLandArea) return getContractScaleProjectTotalsByRow(row, projectTotals).landArea
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap)
return typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null
},
isSameValue: isSameNullableNumber,
applyValue: (row, nextValue) => {
row.landArea = nextValue
},
afterApply: async () => {
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
})
}
const restoreConsultCategoryFactorColumnDefaults = async () => {
await loadFactorDefaults()
const nextConsultFactor = getDefaultConsultCategoryFactor()
await restoreScaleColumnDefaults({
gridApi: gridApi.value,
rows: detailRows.value,
getCurrentValue: row => row.consultCategoryFactor,
getNextValue: () => nextConsultFactor,
isSameValue: isSameNullableNumber,
applyValue: (row, nextValue) => {
row.consultCategoryFactor = nextValue
},
afterApply: async () => {
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
})
}
const restoreMajorFactorColumnDefaults = async () => {
await loadFactorDefaults()
await restoreScaleColumnDefaults({
gridApi: gridApi.value,
rows: detailRows.value,
getCurrentValue: row => row.majorFactor,
getNextValue: row => getDefaultMajorFactorById(resolveRowMajorDictId(row)),
isSameValue: isSameNullableNumber,
applyValue: (row, nextValue) => {
row.majorFactor = nextValue
},
afterApply: async () => {
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
})
}
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
createScaleValueColumn<DetailRow>({
headerName: t('pricingScale.columns.landArea'),
field: 'landArea',
headerTooltip: t('pricingScale.tooltip.resetLandArea'),
headerComponent: AgGridResetHeader,
onReset: restoreLandAreaColumnDefaults,
resetTitle: t('pricingScale.tooltip.resetLandArea'),
minWidth: 90,
flex: 2,
isEditable: row => Boolean(row?.hasArea),
emptyTextPredicate: (row, value) => Boolean(row?.hasArea) && (value == null || value === ''),
valueParser: params => {
const value = parseNumberOrNull(params.newValue)
return value == null ? null : roundTo(value, 3)
},
valueFormatter: formatEditableFlexibleNumber
}),
createScaleBenchmarkBudgetColumnGroup<DetailRow>({
getCheckedSplit: getCheckedBenchmarkBudgetSplitByLandArea,
createBudgetCellRendererWithCheck,
getHeaderComponent: () => ScaleBudgetToggleHeader,
getHeaderComponentParams: getBenchmarkBudgetHeaderParams
}),
createScaleBudgetFeeColumnGroup<DetailRow>({
headerComponent: AgGridResetHeader,
restoreConsultCategoryFactorColumnDefaults,
restoreMajorFactorColumnDefaults,
parseNumberOrNull,
getBudgetFee,
aggFunc: decimalAggSum
}),
createScaleRemarkColumn<DetailRow>()
]
const gridColumnDefs = computed(() => withReadonlyAutoHeight(columnDefs))
const autoGroupColumnDef: ColDef = createScaleAutoGroupColumn<DetailRow>({
totalLabel: totalLabel.value,
idLabelMap,
parseProjectIndexFromPathKey
})
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, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() =>
createPinnedTopRowData(
createScalePinnedTotalRow<DetailRow>({
landArea: null,
budgetFee: totalBudgetFee.value,
budgetFeeBasic: totalBudgetFeeBasic.value,
budgetFeeOptional: totalBudgetFeeOptional.value
})
)
)
const syncComputedValuesToDetailRows = () => {
recomputeScaleDetailRowsInPlace(detailRows.value, 'area')
}
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
const saveToIndexedDB = async (options?: { skipComputedSync?: boolean }) => {
if (shouldSkipPersist()) return
try {
if (!options?.skipComputedSync) {
syncComputedValuesToDetailRows()
}
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())),
projectCount: getTargetProjectCount()
}
zxFwPricingStore.setServicePricingMethodState(
props.contractId,
props.serviceId,
'landScale',
payload,
{ force: true }
)
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 syncLinkedFieldsFromContractAndFactors = async () => {
if (detailRows.value.length === 0) return
const consultChangeState =
(zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
?? null) as FactorChangeState | null
const majorChangeState =
(zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
?? null) as FactorChangeState | null
const consultUpdatedAt = Number(consultChangeState?.updatedAt)
const majorUpdatedAt = Number(majorChangeState?.updatedAt)
const hasNewConsultChange = Number.isFinite(consultUpdatedAt) && consultUpdatedAt > lastAppliedConsultFactorChangeAt.value
const hasNewMajorChange = Number.isFinite(majorUpdatedAt) && majorUpdatedAt > lastAppliedMajorFactorChangeAt.value
const consultChangedRowIds = new Set(
(consultChangeState?.changedRowIds || []).map(id => String(id || '').trim()).filter(Boolean)
)
const majorChangedRowIdSet = normalizeChangedScaleRowIds(majorChangeState?.changedRowIds)
const shouldSyncConsultFactor = hasNewConsultChange && consultChangedRowIds.has(String(props.serviceId).trim())
const shouldSyncMajorFactor = hasNewMajorChange && majorChangedRowIdSet.size > 0
if (hasNewConsultChange) lastAppliedConsultFactorChangeAt.value = consultUpdatedAt
if (hasNewMajorChange) lastAppliedMajorFactorChangeAt.value = majorUpdatedAt
if (!shouldSyncConsultFactor && !shouldSyncMajorFactor) return
await loadFactorDefaults()
const consultFactor = getDefaultConsultCategoryFactor()
let changed = false
for (const row of detailRows.value) {
if (shouldSyncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, consultFactor)) {
row.consultCategoryFactor = consultFactor
changed = true
}
if (!shouldSyncMajorFactor) continue
const majorDictId = resolveRowMajorDictId(row)
if (!majorChangedRowIdSet.has(majorDictId)) continue
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
if (isSameNullableNumber(row.majorFactor, nextMajorFactor)) continue
row.majorFactor = nextMajorFactor
changed = true
}
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const syncLinkedScaleValuesFromContract = async (changedRowIds?: string[]) => {
if (detailRows.value.length === 0) return
const htData = await kvStore.getItem<{ detailRows: SourceRow[]; totalAmount?: number | null }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows)
const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount)
const useSummaryLandArea = detailRows.value.length === 1
const changedRowIdSet = changedRowIds?.length ? normalizeChangedScaleRowIds(changedRowIds) : null
let changed = false
for (const row of detailRows.value) {
if (changedRowIdSet && !useSummaryLandArea) {
const rowMajorId = resolveRowMajorDictId(row)
if (!changedRowIdSet.has(rowMajorId)) continue
}
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap, sourceRowIdMap)
const nextLandArea = row.hasArea
? (useSummaryLandArea
? getContractScaleProjectTotalsByRow(row, projectTotals).landArea
: (typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null))
: null
if (isSameNullableNumber(row.landArea, nextLandArea)) continue
row.landArea = nextLandArea
changed = true
}
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const getRowId = (params: { data?: DetailRow }) => String(params.data?.id || '')
const detailGridOptions: GridOptions<DetailRow> = {
...gridOptions,
getRowId
}
const linkedSourceSignature = computed(() => JSON.stringify({
consultFactorChange:
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_CHANGE_KEY.value]
?? kvStore.entries[HT_CONSULT_FACTOR_CHANGE_KEY.value]
?? null,
majorFactorChange:
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_CHANGE_KEY.value]
?? kvStore.entries[HT_MAJOR_FACTOR_CHANGE_KEY.value]
?? null
}))
const linkedContractScaleSignature = computed(() => JSON.stringify(
kvStore.entries[HT_SCALE_CHANGE_KEY.value] ?? null
))
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 readBaseIndustryCode = async () => {
const baseInfo = await kvStore.getItem<XmBaseInfoState>(BASE_INFO_KEY.value)
return typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
}
const buildRowsFromStoredState = (rows: DetailRow[]) =>
mergeWithDictRows(rows as any, {
projectCount: getTargetProjectCount(),
cloneFromProjectOne: true
})
const buildEmptyRows = (targetProjectCount: number) => buildDefaultRows(targetProjectCount)
const applyDetailRows = (rows: DetailRow[]) => {
detailRows.value = rows.map(row => ({
...row,
benchmarkBudgetBasicChecked:
typeof row.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
benchmarkBudgetOptionalChecked:
typeof row.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true
}))
}
const relabelDetailRowsFromDict = async () => {
rebuildScaleDictCaches()
if (detailRows.value.length === 0) {
gridApi.value?.refreshCells({ force: true })
return
}
const majorMetaMap = new Map<string, Pick<DetailRow, 'groupCode' | 'groupName' | 'majorCode' | 'majorName'>>()
for (const group of detailDict) {
majorMetaMap.set(group.id, {
groupCode: group.code,
groupName: group.name,
majorCode: group.code,
majorName: group.name
})
for (const child of group.children) {
majorMetaMap.set(child.id, {
groupCode: group.code,
groupName: group.name,
majorCode: child.code,
majorName: child.name
})
}
}
let changed = false
detailRows.value = detailRows.value.map(row => {
const meta = majorMetaMap.get(resolveRowMajorDictId(row))
if (!meta) return row
if (
row.groupCode === meta.groupCode &&
row.groupName === meta.groupName &&
row.majorCode === meta.majorCode &&
row.majorName === meta.majorName
) {
return row
}
changed = true
return {
...row,
groupCode: meta.groupCode,
groupName: meta.groupName,
majorCode: meta.majorCode,
majorName: meta.majorName
}
})
gridApi.value?.refreshCells({ force: true })
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const mergeProjectExpandedRow = (defaultRow: DetailRow, existingRow: DetailRow): DetailRow => ({
...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
})
const persistProjectCountChange = async () => {
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const applyProjectCountChange = async (nextValue: unknown) => {
await applyPricingScaleProjectCountChange({
nextValue,
setProjectCount: count => {
projectCount.value = count
},
isMutipleService: isMutipleService.value,
currentRows: detailRows.value,
cloneRows: rows => rows.map(row => ({ ...row })),
normalizeProjectCount,
inferProjectCountFromRows: rows => inferProjectCountFromRows(rows),
buildRowsForReducedCount: (rows, targetProjectCount) =>
mergeWithDictRows(rows as any, { projectCount: targetProjectCount }),
buildRowsFromImportDefaultSource,
getRowKey: row => getScaleProjectMajorKeyFromRow(row),
getRowProjectIndex: row => resolveRowProjectIndex(row),
mergeExistingRow: mergeProjectExpandedRow,
applyRows: applyDetailRows,
afterApplyRows: persistProjectCountChange
})
}
const loadFromIndexedDB = async () => {
await loadPricingScalePaneRows({
industry: {
readIndustryCode: readBaseIndustryCode,
setIndustryCode: code => {
activeIndustryCode.value = code
}
},
setProjectCount: count => {
projectCount.value = count
},
ensureFactorDefaultsLoaded,
shouldForceDefaultLoad,
buildContractDefaultRows: buildRowsFromImportDefaultSource,
loadStoredState: () =>
zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'landScale'),
isMutipleService: isMutipleService.value,
normalizeProjectCount,
inferProjectCountFromRows: rows => inferProjectCountFromRows(rows as any),
buildRowsFromStoredState,
buildEmptyRows,
getTargetProjectCount,
applyRows: applyDetailRows,
afterApplyRows: syncComputedValuesToDetailRows,
onError: error => {
console.error('loadFromIndexedDB failed:', error)
}
})
}
const importContractData = async () => {
await importPricingScalePaneRows({
industry: {
readIndustryCode: readBaseIndustryCode,
setIndustryCode: code => {
activeIndustryCode.value = code
}
},
getTargetProjectCount,
buildContractDefaultRows: buildRowsFromImportDefaultSource,
applyRows: applyDetailRows,
saveRows: () => saveToIndexedDB(),
onError: error => {
console.error('importContractData failed:', error)
}
})
}
const clearAllData = async () => {
await clearPricingScalePaneRows({
getTargetProjectCount,
buildEmptyRows,
applyRows: applyDetailRows,
saveRows: () => saveToIndexedDB(),
onError: error => {
console.error('clearAllData failed:', error)
}
})
}
let isBulkClipboardMutation = false
const commitGridChanges = async (source: string) => {
await nextTick()
syncComputedValuesToDetailRows()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
await saveToIndexedDB({ skipComputedSync: true })
await nextTick()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
}
const handleCellValueChanged = (event?: any) => {
if (isBulkClipboardMutation) return
void commitGridChanges('cell-value-changed')
}
const handleBulkMutationStart = () => {
isBulkClipboardMutation = true
}
const handleBulkMutationEnd = (event?: any) => {
isBulkClipboardMutation = false
void commitGridChanges(event?.type || 'bulk-end')
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
gridApi.value = event.api
}
usePricingPaneLifecycle({
gridApi,
loadFromIndexedDB,
syncLinkedFields: syncLinkedFieldsFromContractAndFactors,
linkedSourceSignature,
linkedSecondarySignature: linkedContractScaleSignature,
syncSecondaryLinkedFields: () => {
const state = (kvStore.entries[HT_SCALE_CHANGE_KEY.value] as ContractScaleChangeState | undefined) ?? undefined
return syncLinkedScaleValuesFromContract(state?.changedRowIds)
},
saveToIndexedDB: () => saveToIndexedDB()
})
watch(
() => locale.value,
() => {
void relabelDetailRowsFromDict()
}
)
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); // 数组转字符串复制
}
return params.value;
};
const processCellFromClipboard = (params: any) => {
const field = String(params.column?.getColDef?.().field || '')
if (field === 'landArea' || field === 'consultCategoryFactor' || field === 'majorFactor' || field === 'workStageFactor') {
return parseNumberOrNull(params.value, { precision: 3 })
}
if (field === 'workRatio') {
return parseNumberOrNull(params.value, { precision: 2 })
}
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">{{ t('pricingPane.land.title') }}</h3>
<div v-if="isMutipleService" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">{{ t('pricingPane.projectCount') }}</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">{{ t('common.clear') }}</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">{{ t('pricingPane.clearTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('pricingPane.land.clearDesc') }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="clearAllData">{{ t('pricingPane.confirmClear') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">{{ t('pricingPane.useDefault') }}</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">{{ t('pricingPane.overrideTitle') }}</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
{{ t('pricingPane.land.overrideDesc') }}
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">{{ t('common.cancel') }}</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button @click="importContractData">{{ t('pricingPane.confirmOverride') }}</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
</div>
</div>
<div :class="agGridWrapClass">
<AgGridVue :style="agGridStyle" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
:columnDefs="gridColumnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
:animateRows="true"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
: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>