This commit is contained in:
wintsa 2026-03-23 15:28:04 +08:00
parent c4d04cbee3
commit a280dfb975
23 changed files with 2000 additions and 163 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

BIN
codex.exe

Binary file not shown.

View File

@ -322,7 +322,9 @@ const columnDefs: ColDef<SummaryRow>[] = [
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
colSpan: params => {
if (!params.data) return 1
if (params.data.rowType === 'total') return 4
@ -340,7 +342,9 @@ const columnDefs: ColDef<SummaryRow>[] = [
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
@ -352,7 +356,9 @@ const columnDefs: ColDef<SummaryRow>[] = [
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
@ -364,7 +370,9 @@ const columnDefs: ColDef<SummaryRow>[] = [
minWidth: 110,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => {
if (params.data?.rowType === 'total') return ''
return params.value == null ? '' : formatThousandsFlexible(params.value, 3)
@ -376,7 +384,9 @@ const columnDefs: ColDef<SummaryRow>[] = [
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
@ -385,7 +395,9 @@ const columnDefs: ColDef<SummaryRow>[] = [
minWidth: 120,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
}
]

View File

@ -337,9 +337,9 @@ const summaryView = markRaw(
const xmCategories: XmCategoryItem[] = [
{ key: 'base-info', label: '基础信息', component: htBaseInfoView },
{ key: 'info', label: '规模信息', component: htView },
{ key: 'contract', label: '咨询服务', component: zxfwView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView },
{ key: 'contract', label: '咨询服务', component: zxfwView },
{ key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView },
{ key: 'reserve-fee', label: '预备费', component: reserveFeeView },
{ key: 'all', label: '汇总', component: summaryView },

View File

@ -401,8 +401,10 @@ const createMethodColumn = (
headerClass: 'ag-right-aligned-header',
minWidth,
flex: 1.5,
cellClass: 'ag-right-aligned-cell',
editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getMethodTotal(field)
@ -656,8 +658,10 @@ const columnDefs: ColDef<DetailRow>[] = [
headerClass: 'ag-right-aligned-header',
flex: 2,
minWidth: 100,
cellClass: 'ag-right-aligned-cell',
editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => {
if (!params.data) return null
return params.data.subtotal
@ -665,13 +669,16 @@ const columnDefs: ColDef<DetailRow>[] = [
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value, 2))
},
{
headerName: '确认金额',
headerName: '确认金额',
field: 'finalFee',
headerClass: 'ag-right-aligned-header',
headerTooltip: '该列支持手动修改,修改后会自动汇总到固定小计行',
flex: 2,
minWidth: 110,
cellClass: 'ag-right-aligned-cell',
editable: params => !isFixedRow(params.data),
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => {
if (!params.data) return null
return params.data.finalFee

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorIdInIndustryScope } from '@/sql'
@ -12,6 +12,7 @@ import { useKvStore } from '@/pinia/kv'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
import { AgGridResetHeader } from '@/lib/agGridResetHeader'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
@ -616,23 +617,22 @@ const formatReadonlyMoney = (params: any) => {
}
type BudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
const isBenchmarkBudgetFullyUnchecked = (
row?: Pick<DetailRow, 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
) => row?.benchmarkBudgetBasicChecked === false && row?.benchmarkBudgetOptionalChecked === false
const updateBudgetCheckState = (rowId: string, checkField: BudgetCheckField, checked: boolean) => {
detailRows.value = detailRows.value.map(row => {
if (row.id !== rowId) return row
for (const row of detailRows.value) {
if (row.id !== rowId) continue
if (checkField === 'benchmarkBudgetBasicChecked') {
return {
...row,
benchmarkBudgetBasicChecked: checked,
benchmarkBudgetBasic: checked ? row.benchmarkBudgetBasic : 0
row.benchmarkBudgetBasicChecked = checked
row.benchmarkBudgetBasic = checked ? row.benchmarkBudgetBasic : 0
return
}
row.benchmarkBudgetOptionalChecked = checked
row.benchmarkBudgetOptional = checked ? row.benchmarkBudgetOptional : 0
return
}
return {
...row,
benchmarkBudgetOptionalChecked: checked,
benchmarkBudgetOptional: checked ? row.benchmarkBudgetOptional : 0
}
})
}
const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => {
@ -648,28 +648,42 @@ const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (par
wrapper.style.justifyContent = 'space-between'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
wrapper.addEventListener('pointerdown', event => event.stopPropagation())
wrapper.addEventListener('mousedown', event => event.stopPropagation())
wrapper.addEventListener('click', event => event.stopPropagation())
wrapper.addEventListener('dblclick', event => event.stopPropagation())
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('pointerdown', event => event.stopPropagation())
checkbox.addEventListener('mousedown', event => event.stopPropagation())
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', () => {
checkbox.addEventListener('change', event => {
event.stopPropagation()
const targetRow = params.data as DetailRow | undefined
if (!targetRow) return
updateBudgetCheckState(targetRow.id, checkField, checkbox.checked)
handleCellValueChanged()
void nextTick(() => {
params.api?.redrawRows?.({
rowNodes: params.node ? [params.node] : undefined
})
params.api?.refreshCells?.({
rowNodes: params.node ? [params.node] : undefined,
force: true
})
})
})
const valueSpan = document.createElement('span')
valueSpan.textContent = valueText
valueSpan.addEventListener('pointerdown', event => event.stopPropagation())
valueSpan.addEventListener('mousedown', event => event.stopPropagation())
valueSpan.addEventListener('click', event => event.stopPropagation())
wrapper.append(checkbox, valueSpan)
return wrapper
@ -685,11 +699,12 @@ const getCheckedBenchmarkBudgetSplitByAmount = (
if (!split) return null
const basic = row?.benchmarkBudgetBasicChecked === false ? 0 : split.basic
const optional = row?.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
const total = isBenchmarkBudgetFullyUnchecked(row) ? null : roundTo(addNumbers(basic, optional), 2)
return {
...split,
basic,
optional,
total: roundTo(addNumbers(basic, optional), 2)
total
}
}
@ -705,6 +720,7 @@ const getBudgetFee = (
| 'workRatio'
>
) => {
if (isBenchmarkBudgetFullyUnchecked(row)) return null
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByAmount(row)
if (!benchmarkBudgetSplit) return null
@ -731,6 +747,7 @@ const getBudgetFeeSplit = (
| 'workRatio'
>
) => {
if (isBenchmarkBudgetFullyUnchecked(row)) return null
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByAmount(row)
if (!benchmarkBudgetSplit) return null
return getScaleBudgetFeeSplit({
@ -753,10 +770,79 @@ const getMergeColSpanBeforeTotal = (params: any) => {
return totalIndex - currentIndex
}
const refreshGridAfterColumnReset = async () => {
await nextTick()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
}
const restoreAmountColumnDefaults = async () => {
gridApi.value?.stopEditing()
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows)
const onlyCostScaleFallbackAmount = isOnlyCostScaleService.value
? calcOnlyCostScaleAmountFromRows(sourceRows as Array<{ amount?: unknown }>)
: null
let changed = false
for (const row of detailRows.value) {
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap)
const nextAmount = row.hasCost
? (typeof sourceRow?.amount === 'number' ? sourceRow.amount : onlyCostScaleFallbackAmount)
: null
if (isSameNullableNumber(row.amount, nextAmount)) continue
row.amount = nextAmount
changed = true
}
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
await refreshGridAfterColumnReset()
}
const restoreConsultCategoryFactorColumnDefaults = async () => {
gridApi.value?.stopEditing()
await loadFactorDefaults()
const nextConsultFactor = getDefaultConsultCategoryFactor()
let changed = false
for (const row of detailRows.value) {
if (isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)) continue
row.consultCategoryFactor = nextConsultFactor
changed = true
}
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
await refreshGridAfterColumnReset()
}
const restoreMajorFactorColumnDefaults = async () => {
gridApi.value?.stopEditing()
await loadFactorDefaults()
let changed = false
for (const row of detailRows.value) {
const nextMajorFactor = getDefaultMajorFactorById(resolveRowMajorDictId(row))
if (isSameNullableNumber(row.majorFactor, nextMajorFactor)) continue
row.majorFactor = nextMajorFactor
changed = true
}
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
await refreshGridAfterColumnReset()
}
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{
headerName: '造价金额(万元)',
field: 'amount',
headerTooltip: '点击右侧↻恢复本列默认造价金额',
headerComponent: AgGridResetHeader,
headerComponentParams: {
onReset: restoreAmountColumnDefaults,
resetTitle: '恢复本列默认造价金额'
},
headerClass: 'ag-right-aligned-header',
minWidth: 90,
flex: 2,
@ -766,9 +852,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
},
cellClass: params =>
!params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? ' editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell':()=>true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
},
@ -786,12 +873,17 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params =>
params.node?.rowPinned
? null
: getCheckedBenchmarkBudgetSplitByAmount(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatReadonlyMoney
},
{
@ -801,12 +893,17 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params =>
params.node?.rowPinned
? null
: getCheckedBenchmarkBudgetSplitByAmount(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatReadonlyMoney
},
{
@ -816,7 +913,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params =>
params.node?.rowPinned
? null
@ -833,15 +932,22 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
colId: 'consultCategoryFactor',
headerTooltip: '点击右侧↻恢复本列默认咨询分类系数',
headerComponent: AgGridResetHeader,
headerComponentParams: {
onReset: restoreConsultCategoryFactorColumnDefaults,
resetTitle: '恢复本列默认咨询分类系数'
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -852,15 +958,22 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerName: '专业系数',
field: 'majorFactor',
colId: 'majorFactor',
headerTooltip: '点击右侧↻恢复本列默认专业系数',
headerComponent: AgGridResetHeader,
headerComponentParams: {
onReset: restoreMajorFactorColumnDefaults,
resetTitle: '恢复本列默认专业系数'
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -877,9 +990,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -896,9 +1010,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -912,8 +1027,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueFormatter: formatReadonlyMoney
}
@ -986,7 +1103,9 @@ const autoGroupColumnDef: ColDef = {
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
const raw = pick(row)
if (raw == null || raw === '') return null
const value = Number(raw)
if (!Number.isFinite(value)) return null
hasValid = true
return value
@ -996,9 +1115,7 @@ const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | 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 totalBudgetFee = computed(() => sumNullableBy(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => {
return [
{
@ -1058,14 +1175,14 @@ const syncComputedValuesToDetailRows = () => {
}
}
const buildPersistDetailRows = () => {
syncComputedValuesToDetailRows()
return detailRows.value.map(row => ({ ...row }))
}
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
const saveToIndexedDB = async () => {
const saveToIndexedDB = async (options?: { skipComputedSync?: boolean }) => {
if (shouldSkipPersist()) return
try {
if (!options?.skipComputedSync) {
syncComputedValuesToDetailRows()
}
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())),
projectCount: getTargetProjectCount()
@ -1089,6 +1206,101 @@ const saveToIndexedDB = async () => {
}
}
const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
if (left == null && right == null) return true
if (left == null || right == null) return false
return roundTo(left, 6) === roundTo(right, 6)
}
const buildContractScaleMap = (rows: SourceRow[] | undefined) => {
const map = new Map<string, SourceRow>()
for (const row of rows || []) {
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) continue
const projectIndex = resolveRowProjectIndex(row)
map.set(makeProjectMajorKey(projectIndex, majorDictId), row)
}
return map
}
const getContractScaleRowByMajor = (row: DetailRow, map: Map<string, SourceRow>) => {
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) return undefined
const projectIndex = resolveRowProjectIndex(row)
return map.get(makeProjectMajorKey(projectIndex, majorDictId))
|| (projectIndex > 1 ? map.get(makeProjectMajorKey(1, majorDictId)) : undefined)
}
const syncLinkedFieldsFromContractAndFactors = async () => {
if (detailRows.value.length === 0) return
await loadFactorDefaults()
const consultFactor = getDefaultConsultCategoryFactor()
let changed = false
detailRows.value = detailRows.value.map(row => {
const majorDictId = resolveRowMajorDictId(row)
const nextConsultFactor = consultFactor
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
if (
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
) {
return row
}
changed = true
return {
...row,
consultCategoryFactor: nextConsultFactor,
majorFactor: nextMajorFactor
}
})
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const syncLinkedScaleValuesFromContract = async () => {
if (detailRows.value.length === 0) return
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows)
const onlyCostScaleFallbackAmount = isOnlyCostScaleService.value
? calcOnlyCostScaleAmountFromRows(sourceRows as Array<{ amount?: unknown }>)
: null
let changed = false
detailRows.value = detailRows.value.map(row => {
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap)
const nextAmount = row.hasCost
? (typeof sourceRow?.amount === 'number' ? sourceRow.amount : onlyCostScaleFallbackAmount)
: null
if (isSameNullableNumber(row.amount, nextAmount)) return row
changed = true
return {
...row,
amount: nextAmount
}
})
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const linkedSourceSignature = computed(() => JSON.stringify({
consultFactor:
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
?? null,
majorFactor:
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_KEY.value]
?? kvStore.entries[HT_MAJOR_FACTOR_KEY.value]
?? null
}))
const linkedContractScaleSignature = computed(() => JSON.stringify(
kvStore.entries[HT_DB_KEY.value] ?? null
))
const getProjectMajorKeyFromRow = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const majorDictId = resolveRowMajorDictId(row)
@ -1134,7 +1346,7 @@ const applyProjectCountChange = async (nextValue: unknown) => {
? buildOnlyCostScaleRows(previousRows as any, { projectCount: normalized })
: mergeWithDictRows(previousRows as any, { projectCount: normalized })
syncComputedValuesToDetailRows()
await saveToIndexedDB()
await saveToIndexedDB({ skipComputedSync: true })
return
}
@ -1167,7 +1379,7 @@ const applyProjectCountChange = async (nextValue: unknown) => {
}
})
syncComputedValuesToDetailRows()
await saveToIndexedDB()
await saveToIndexedDB({ skipComputedSync: true })
}
const loadFromIndexedDB = async () => {
@ -1282,7 +1494,7 @@ const clearAllData = async () => {
const handleCellValueChanged = () => {
syncComputedValuesToDetailRows()
void saveToIndexedDB()
void saveToIndexedDB({ skipComputedSync: true })
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
@ -1291,10 +1503,12 @@ const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
onMounted(async () => {
await loadFromIndexedDB()
await syncLinkedFieldsFromContractAndFactors()
})
onActivated(() => {
void loadFromIndexedDB()
onActivated(async () => {
await loadFromIndexedDB()
await syncLinkedFieldsFromContractAndFactors()
})
onBeforeUnmount(() => {
@ -1302,6 +1516,14 @@ onBeforeUnmount(() => {
gridApi.value = null
void saveToIndexedDB()
})
watch(linkedSourceSignature, () => {
void syncLinkedFieldsFromContractAndFactors()
})
watch(linkedContractScaleSignature, () => {
void syncLinkedScaleValuesFromContract()
})
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); //

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onActivated, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, ColGroupDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { getMajorDictEntries, getServiceDictItemById, industryTypeList, isMajorIdInIndustryScope } from '@/sql'
@ -12,6 +12,7 @@ import { useKvStore } from '@/pinia/kv'
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
import { parseNumberOrNull } from '@/lib/number'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
import { AgGridResetHeader } from '@/lib/agGridResetHeader'
import { Button } from '@/components/ui/button'
import {
AlertDialogAction,
@ -481,23 +482,22 @@ const formatReadonlyMoney = (params: any) => {
}
type BudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
const isBenchmarkBudgetFullyUnchecked = (
row?: Pick<DetailRow, 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
) => row?.benchmarkBudgetBasicChecked === false && row?.benchmarkBudgetOptionalChecked === false
const updateBudgetCheckState = (rowId: string, checkField: BudgetCheckField, checked: boolean) => {
detailRows.value = detailRows.value.map(row => {
if (row.id !== rowId) return row
for (const row of detailRows.value) {
if (row.id !== rowId) continue
if (checkField === 'benchmarkBudgetBasicChecked') {
return {
...row,
benchmarkBudgetBasicChecked: checked,
benchmarkBudgetBasic: checked ? row.benchmarkBudgetBasic : 0
row.benchmarkBudgetBasicChecked = checked
row.benchmarkBudgetBasic = checked ? row.benchmarkBudgetBasic : 0
return
}
row.benchmarkBudgetOptionalChecked = checked
row.benchmarkBudgetOptional = checked ? row.benchmarkBudgetOptional : 0
return
}
return {
...row,
benchmarkBudgetOptionalChecked: checked,
benchmarkBudgetOptional: checked ? row.benchmarkBudgetOptional : 0
}
})
}
const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => {
@ -513,26 +513,40 @@ const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (par
wrapper.style.justifyContent = 'space-between'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
wrapper.addEventListener('pointerdown', event => event.stopPropagation())
wrapper.addEventListener('mousedown', event => event.stopPropagation())
wrapper.addEventListener('click', event => event.stopPropagation())
wrapper.addEventListener('dblclick', event => event.stopPropagation())
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('pointerdown', event => event.stopPropagation())
checkbox.addEventListener('mousedown', event => event.stopPropagation())
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', () => {
checkbox.addEventListener('change', event => {
event.stopPropagation()
const targetRow = params.data as DetailRow | undefined
if (!targetRow) return
updateBudgetCheckState(targetRow.id, checkField, checkbox.checked)
handleCellValueChanged()
void nextTick(() => {
params.api?.redrawRows?.({
rowNodes: params.node ? [params.node] : undefined
})
params.api?.refreshCells?.({
rowNodes: params.node ? [params.node] : undefined,
force: true
})
})
})
const valueSpan = document.createElement('span')
valueSpan.textContent = valueText
valueSpan.addEventListener('pointerdown', event => event.stopPropagation())
valueSpan.addEventListener('mousedown', event => event.stopPropagation())
valueSpan.addEventListener('click', event => event.stopPropagation())
wrapper.append(checkbox, valueSpan)
return wrapper
@ -548,11 +562,12 @@ const getCheckedBenchmarkBudgetSplitByLandArea = (
if (!split) return null
const basic = row?.benchmarkBudgetBasicChecked === false ? 0 : split.basic
const optional = row?.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
const total = isBenchmarkBudgetFullyUnchecked(row) ? null : roundTo(addNumbers(basic, optional), 2)
return {
...split,
basic,
optional,
total: roundTo(addNumbers(basic, optional), 2)
total
}
}
@ -568,6 +583,7 @@ const getBudgetFee = (
| 'workRatio'
>
) => {
if (isBenchmarkBudgetFullyUnchecked(row)) return null
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null
@ -594,6 +610,7 @@ const getBudgetFeeSplit = (
| 'workRatio'
>
) => {
if (isBenchmarkBudgetFullyUnchecked(row)) return null
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null
return getScaleBudgetFeeSplit({
@ -627,10 +644,74 @@ const formatEditableFlexibleNumber = (params: any) => {
return formatThousandsFlexible(params.value, 3)
}
const refreshGridAfterColumnReset = async () => {
await nextTick()
gridApi.value?.refreshHeader()
gridApi.value?.refreshCells({ force: true })
}
const restoreLandAreaColumnDefaults = async () => {
gridApi.value?.stopEditing()
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows)
let changed = false
for (const row of detailRows.value) {
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap)
const nextLandArea = row.hasArea ? (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 })
await refreshGridAfterColumnReset()
}
const restoreConsultCategoryFactorColumnDefaults = async () => {
gridApi.value?.stopEditing()
await loadFactorDefaults()
const nextConsultFactor = getDefaultConsultCategoryFactor()
let changed = false
for (const row of detailRows.value) {
if (isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)) continue
row.consultCategoryFactor = nextConsultFactor
changed = true
}
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
await refreshGridAfterColumnReset()
}
const restoreMajorFactorColumnDefaults = async () => {
gridApi.value?.stopEditing()
await loadFactorDefaults()
let changed = false
for (const row of detailRows.value) {
const nextMajorFactor = getDefaultMajorFactorById(resolveRowMajorDictId(row))
if (isSameNullableNumber(row.majorFactor, nextMajorFactor)) continue
row.majorFactor = nextMajorFactor
changed = true
}
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
await refreshGridAfterColumnReset()
}
const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{
headerName: '用地面积(亩)',
field: 'landArea',
headerTooltip: '点击右侧↻恢复本列默认用地面积',
headerComponent: AgGridResetHeader,
headerComponentParams: {
onReset: restoreLandAreaColumnDefaults,
resetTitle: '恢复本列默认用地面积'
},
headerClass: 'ag-right-aligned-header',
minWidth: 90,
@ -639,9 +720,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
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',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
},
@ -662,12 +744,17 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params =>
params.node?.rowPinned
? null
: getCheckedBenchmarkBudgetSplitByLandArea(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatReadonlyMoney
},
{
@ -677,12 +764,17 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params =>
params.node?.rowPinned
? null
: getCheckedBenchmarkBudgetSplitByLandArea(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
cellRendererParams: {
suppressMouseEventHandling: () => true
},
valueFormatter: formatReadonlyMoney
},
{
@ -692,7 +784,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params =>
params.node?.rowPinned
? null
@ -709,15 +803,22 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
colId: 'consultCategoryFactor',
headerTooltip: '点击右侧↻恢复本列默认咨询分类系数',
headerComponent: AgGridResetHeader,
headerComponentParams: {
onReset: restoreConsultCategoryFactorColumnDefaults,
resetTitle: '恢复本列默认咨询分类系数'
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -728,15 +829,22 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerName: '专业系数',
field: 'majorFactor',
colId: 'majorFactor',
headerTooltip: '点击右侧↻恢复本列默认专业系数',
headerComponent: AgGridResetHeader,
headerComponentParams: {
onReset: restoreMajorFactorColumnDefaults,
resetTitle: '恢复本列默认专业系数'
},
headerClass: 'ag-right-aligned-header',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -753,9 +861,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -772,9 +881,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -788,8 +898,10 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
valueFormatter: formatReadonlyMoney
}
@ -864,7 +976,9 @@ const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row =>
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
const raw = pick(row)
if (raw == null || raw === '') return null
const value = Number(raw)
if (!Number.isFinite(value)) return null
hasValid = true
return value
@ -874,9 +988,7 @@ const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | 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 totalBudgetFee = computed(() => sumNullableBy(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@ -909,6 +1021,7 @@ const pinnedTopRowData = computed(() => [
const syncComputedValuesToDetailRows = () => {
for (const row of detailRows.value) {
const benchmarkBudgetRawSplit = getBenchmarkBudgetSplitByLandArea(row)
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByLandArea(row)
const budgetFeeSplit = benchmarkBudgetSplit
@ -935,14 +1048,14 @@ const syncComputedValuesToDetailRows = () => {
}
}
const buildPersistDetailRows = () => {
syncComputedValuesToDetailRows()
return detailRows.value.map(row => ({ ...row }))
}
const buildPersistDetailRows = () => detailRows.value.map(row => ({ ...row }))
const saveToIndexedDB = async () => {
const saveToIndexedDB = async (options?: { skipComputedSync?: boolean }) => {
if (shouldSkipPersist()) return
try {
if (!options?.skipComputedSync) {
syncComputedValuesToDetailRows()
}
const payload = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows())),
projectCount: getTargetProjectCount()
@ -966,6 +1079,96 @@ const saveToIndexedDB = async () => {
}
}
const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
if (left == null && right == null) return true
if (left == null || right == null) return false
return roundTo(left, 6) === roundTo(right, 6)
}
const buildContractScaleMap = (rows: SourceRow[] | undefined) => {
const map = new Map<string, SourceRow>()
for (const row of rows || []) {
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) continue
const projectIndex = resolveRowProjectIndex(row)
map.set(makeProjectMajorKey(projectIndex, majorDictId), row)
}
return map
}
const getContractScaleRowByMajor = (row: DetailRow, map: Map<string, SourceRow>) => {
const majorDictId = resolveRowMajorDictId(row)
if (!majorDictId) return undefined
const projectIndex = resolveRowProjectIndex(row)
return map.get(makeProjectMajorKey(projectIndex, majorDictId))
|| (projectIndex > 1 ? map.get(makeProjectMajorKey(1, majorDictId)) : undefined)
}
const syncLinkedFieldsFromContractAndFactors = async () => {
if (detailRows.value.length === 0) return
await loadFactorDefaults()
const consultFactor = getDefaultConsultCategoryFactor()
let changed = false
detailRows.value = detailRows.value.map(row => {
const majorDictId = resolveRowMajorDictId(row)
const nextConsultFactor = consultFactor
const nextMajorFactor = getDefaultMajorFactorById(majorDictId)
if (
isSameNullableNumber(row.consultCategoryFactor, nextConsultFactor)
&& isSameNullableNumber(row.majorFactor, nextMajorFactor)
) {
return row
}
changed = true
return {
...row,
consultCategoryFactor: nextConsultFactor,
majorFactor: nextMajorFactor
}
})
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const syncLinkedScaleValuesFromContract = async () => {
if (detailRows.value.length === 0) return
const htData = await kvStore.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
const sourceRowMap = buildContractScaleMap(sourceRows)
let changed = false
detailRows.value = detailRows.value.map(row => {
const sourceRow = getContractScaleRowByMajor(row, sourceRowMap)
const nextLandArea = row.hasArea ? (typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null) : null
if (isSameNullableNumber(row.landArea, nextLandArea)) return row
changed = true
return {
...row,
landArea: nextLandArea
}
})
if (!changed) return
syncComputedValuesToDetailRows()
await saveToIndexedDB({ skipComputedSync: true })
}
const linkedSourceSignature = computed(() => JSON.stringify({
consultFactor:
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
?? null,
majorFactor:
zxFwPricingStore.keyedStates[HT_MAJOR_FACTOR_KEY.value]
?? kvStore.entries[HT_MAJOR_FACTOR_KEY.value]
?? null
}))
const linkedContractScaleSignature = computed(() => JSON.stringify(
kvStore.entries[HT_DB_KEY.value] ?? null
))
const getProjectMajorKeyFromRow = (row: Partial<DetailRow> | undefined) => {
if (!row) return ''
const majorDictId = resolveRowMajorDictId(row)
@ -1004,7 +1207,7 @@ const applyProjectCountChange = async (nextValue: unknown) => {
if (normalized < previousProjectCount) {
detailRows.value = mergeWithDictRows(previousRows as any, { projectCount: normalized })
syncComputedValuesToDetailRows()
await saveToIndexedDB()
await saveToIndexedDB({ skipComputedSync: true })
return
}
@ -1037,7 +1240,7 @@ const applyProjectCountChange = async (nextValue: unknown) => {
}
})
syncComputedValuesToDetailRows()
await saveToIndexedDB()
await saveToIndexedDB({ skipComputedSync: true })
}
const loadFromIndexedDB = async () => {
@ -1131,7 +1334,7 @@ const clearAllData = async () => {
const handleCellValueChanged = () => {
syncComputedValuesToDetailRows()
void saveToIndexedDB()
void saveToIndexedDB({ skipComputedSync: true })
}
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
@ -1140,10 +1343,12 @@ const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
onMounted(async () => {
await loadFromIndexedDB()
await syncLinkedFieldsFromContractAndFactors()
})
onActivated(() => {
void loadFromIndexedDB()
onActivated(async () => {
await loadFromIndexedDB()
await syncLinkedFieldsFromContractAndFactors()
})
onBeforeUnmount(() => {
@ -1151,6 +1356,14 @@ onBeforeUnmount(() => {
gridApi.value = null
void saveToIndexedDB()
})
watch(linkedSourceSignature, () => {
void syncLinkedFieldsFromContractAndFactors()
})
watch(linkedContractScaleSignature, () => {
void syncLinkedScaleValuesFromContract()
})
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); //

View File

@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
import { taskList } from '@/sql'
@ -9,6 +9,7 @@ import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { useKvStore } from '@/pinia/kv'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue'
@ -42,6 +43,7 @@ const props = defineProps<{
serviceId: string | number
}>()
const zxFwPricingStore = useZxFwPricingStore()
const kvStore = useKvStore()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
@ -193,7 +195,7 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
budgetAdoptedUnitPrice:
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
consultCategoryFactor:
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : getDefaultConsultCategoryFactor(),
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : null,
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
}
@ -304,8 +306,9 @@ const columnDefs: ColDef<DetailRow>[] = [
minWidth: 170,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group &&
!params.node?.rowPinned &&
@ -365,8 +368,10 @@ const columnDefs: ColDef<DetailRow>[] = [
headerClass: 'ag-right-aligned-header',
minWidth: 150,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)),
aggFunc: decimalAggSum,
valueFormatter: params => {
@ -414,7 +419,7 @@ const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null =
return hasValid ? total : null
}
const totalServiceFee = computed(() => sumNullableBy(detailRows.value.filter(e => e.basicFee !== null && e.basicFee !== undefined), row => calcServiceFee(row)))
const totalServiceFee = computed(() => sumNullableBy(detailRows.value, row => calcServiceFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',
@ -468,6 +473,37 @@ const saveToIndexedDB = async () => {
}
}
const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
if (left == null && right == null) return true
if (left == null || right == null) return false
return roundTo(left, 6) === roundTo(right, 6)
}
const syncLinkedConsultFactorFromHt = async () => {
if (!isWorkloadMethodApplicable.value || detailRows.value.length === 0) return
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
factorDefaultsLoaded = true
const nextDefaultFactor = getDefaultConsultCategoryFactor()
let changed = false
detailRows.value = detailRows.value.map(row => {
if (isSameNullableNumber(row.consultCategoryFactor, nextDefaultFactor)) return row
changed = true
return {
...row,
consultCategoryFactor: nextDefaultFactor
}
})
if (!changed) return
await saveToIndexedDB()
}
const linkedConsultFactorSignature = computed(() => JSON.stringify({
consultFactor:
zxFwPricingStore.keyedStates[HT_CONSULT_FACTOR_KEY.value]
?? kvStore.entries[HT_CONSULT_FACTOR_KEY.value]
?? null
}))
const loadFromIndexedDB = async () => {
try {
if (!isWorkloadMethodApplicable.value) {
@ -505,6 +541,12 @@ const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
onMounted(async () => {
await loadFromIndexedDB()
await syncLinkedConsultFactorFromHt()
})
onActivated(async () => {
await loadFromIndexedDB()
await syncLinkedConsultFactorFromHt()
})
onBeforeUnmount(() => {
@ -512,6 +554,10 @@ onBeforeUnmount(() => {
gridApi.value = null
void saveToIndexedDB()
})
watch(linkedConsultFactorSignature, () => {
void syncLinkedConsultFactorFromHt()
})
const processCellForClipboard = (params: any) => {
if (Array.isArray(params.value)) {
return JSON.stringify(params.value); //

View File

@ -304,6 +304,7 @@ const editableNumberCol = <K extends keyof DetailRow>(
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'ag-right-aligned-cell':()=>true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -325,9 +326,10 @@ const editableMoneyCol = <K extends keyof DetailRow>(
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params =>
!params.node?.group && !params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
@ -403,8 +405,10 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
headerClass: 'ag-right-aligned-header',
minWidth: 120,
flex: 1,
cellClass: 'ag-right-aligned-cell',
editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
aggFunc: decimalAggSum,
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
valueFormatter: params => {
@ -446,7 +450,7 @@ const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null =
})
return hasValid ? total : null
}
const totalServiceBudget = computed(() => sumNullableBy(detailRows.value.filter(e => e.serviceBudget !== null && e.serviceBudget !== undefined), row => calcServiceBudget(row)))
const totalServiceBudget = computed(() => sumNullableBy(detailRows.value, row => calcServiceBudget(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',

View File

@ -309,11 +309,12 @@ const columnDefs: ColDef<FeeRow>[] = [
minWidth: 100,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableQuantity,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
@ -323,11 +324,12 @@ const columnDefs: ColDef<FeeRow>[] = [
minWidth: 120,
flex: 1.1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
cellClass: 'editable-cell-line',
editable: params => !isSubtotalRow(params.data),
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
@ -337,8 +339,10 @@ const columnDefs: ColDef<FeeRow>[] = [
minWidth: 130,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
editable: false,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: formatReadonlyBudgetFee
},
{

View File

@ -510,10 +510,10 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
@ -524,10 +524,10 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
@ -538,10 +538,10 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
valueParser: params => numericParser(params.newValue),
valueFormatter: formatEditableNumber,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
@ -552,7 +552,9 @@ const columnDefs: ColDef<FeeMethodRow>[] = [
flex: 1.2,
editable: false,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueGetter: params => getRowSubtotal(params.data),
valueFormatter: formatEditableNumber
},

View File

@ -160,8 +160,10 @@ const columnDefs: ColDef<FactorRow>[] = [
minWidth: 86,
maxWidth: 100,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
flex: 0.9,
cellClassRules: {
'ag-right-aligned-cell': () => true
},
valueFormatter: params => formatReadonlyFactor(params.value)
},
{
@ -172,10 +174,11 @@ const columnDefs: ColDef<FactorRow>[] = [
headerClass: 'ag-right-aligned-header',
cellClass: params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell editable-cell-line'
return disabled ? '' : 'editable-cell-line'
},
flex: 0.9,
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params => {
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
return !disabled && (params.value == null || params.value === '')

View File

@ -338,11 +338,12 @@ const columnDefs: ColDef<DetailRow>[] = [
},
cellClass: params =>
roughCalcEnabled.value && params.node?.rowPinned
? 'ag-right-aligned-cell editable-cell-line'
? 'editable-cell-line'
: !roughCalcEnabled.value && !params.node?.group && !params.node?.rowPinned && params.data?.hasCost
? 'ag-right-aligned-cell editable-cell-line'
: 'ag-right-aligned-cell',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
roughCalcEnabled.value && params.node?.rowPinned
? params.value == null || params.value === ''
@ -379,9 +380,10 @@ const columnDefs: ColDef<DetailRow>[] = [
editable: params => !roughCalcEnabled.value && !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',
? 'editable-cell-line'
: '',
cellClassRules: {
'ag-right-aligned-cell': () => true,
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
},

View File

@ -199,7 +199,7 @@ const confirmQuickCalc = async () => {
tabStore.enterWorkspace({
id: `contract-${QUICK_CONTRACT_ID}`,
title: contractName,
componentName: 'QuickCalcView',
componentName: 'QuickCalcWorkbenchView',
props: {
contractId: QUICK_CONTRACT_ID,
contractName,

File diff suppressed because it is too large Load Diff

View File

@ -126,6 +126,8 @@ interface ZxFwStorageLike {
}
interface ScaleMethodRowLike extends ScaleRowLike {
benchmarkBudgetBasicChecked?: unknown
benchmarkBudgetOptionalChecked?: unknown
basicFormula?: unknown
optionalFormula?: unknown
budgetFee?: unknown
@ -482,6 +484,7 @@ const userGuideSteps: UserGuideStep[] = [
const componentMap: Record<string, any> = {
ProjectCalcView: markRaw(defineAsyncComponent(() => import('@/components/xm/xmCard.vue'))),
QuickCalcView: markRaw(defineAsyncComponent(() => import('@/components/ht/htCard.vue'))),
QuickCalcWorkbenchView: markRaw(defineAsyncComponent(() => import('@/components/views/QuickCalcWorkbenchView.vue'))),
ZxFwView: markRaw(defineAsyncComponent(() => import('@/components/views/ZxFwView.vue'))),
HtFeeMethodTypeLineView: markRaw(defineAsyncComponent(() => import('@/components/views/HtFeeMethodTypeLineView.vue'))),
}
@ -1105,25 +1108,30 @@ const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null =>
const resolveScaleMethodFee = (row: ScaleMethodRowLike, mode: 'cost' | 'area') => {
const scaleValue = mode === 'cost' ? toFiniteNumber(row.amount) : toFiniteNumber(row.landArea)
const benchmarkSplit = getBenchmarkBudgetSplitByScale(scaleValue, mode)
const basicChecked = row.benchmarkBudgetBasicChecked !== false
const optionalChecked = row.benchmarkBudgetOptionalChecked !== false
const allUnchecked = !basicChecked && !optionalChecked
const benchmarkBudgetBasic = benchmarkSplit ? (basicChecked ? benchmarkSplit.basic : 0) : null
const benchmarkBudgetOptional = benchmarkSplit ? (optionalChecked ? benchmarkSplit.optional : 0) : null
const computedSplit = benchmarkSplit
? getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkSplit.basic,
benchmarkBudgetOptional: benchmarkSplit.optional,
benchmarkBudgetBasic,
benchmarkBudgetOptional,
majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
})
: null
const basicFee = toFiniteNumber(row.budgetFee) ?? computedSplit?.total ?? null
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic) ?? computedSplit?.basic ?? null
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional) ?? computedSplit?.optional ?? null
const basicFee = allUnchecked ? null : (toFiniteNumber(row.budgetFee) ?? computedSplit?.total ?? null)
const basicFeeBasic = allUnchecked ? null : (toFiniteNumber(row.budgetFeeBasic) ?? computedSplit?.basic ?? null)
const basicFeeOptional = allUnchecked ? null : (toFiniteNumber(row.budgetFeeOptional) ?? computedSplit?.optional ?? null)
const basicFormula = typeof row.basicFormula === 'string' && row.basicFormula.trim()
? row.basicFormula
: (benchmarkSplit?.basicFormula ?? '')
: (basicChecked ? (benchmarkSplit?.basicFormula ?? '') : '')
const optionalFormula = typeof row.optionalFormula === 'string' && row.optionalFormula.trim()
? row.optionalFormula
: (benchmarkSplit?.optionalFormula ?? '')
: (optionalChecked ? (benchmarkSplit?.optionalFormula ?? '') : '')
return {
basicFee,
basicFeeBasic,
@ -1251,13 +1259,7 @@ const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | n
const basicFeeBasic = feeResolved.basicFeeBasic
const basicFeeOptional = feeResolved.basicFeeOptional
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
cost != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
isNonEmptyString(remark)
if (!hasValue) return null
if (basicFee == null) return null
return {
proNum,
major,
@ -1306,13 +1308,7 @@ const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | n
const basicFeeBasic = feeResolved.basicFeeBasic
const basicFeeOptional = feeResolved.basicFeeOptional
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue =
area != null ||
basicFee != null ||
basicFeeBasic != null ||
basicFeeOptional != null ||
isNonEmptyString(remark)
if (!hasValue) return null
if (basicFee == null) return null
return {
proNum,
major,
@ -2149,7 +2145,7 @@ watch(
</button>
</div>
<div class="flex shrink-0 self-center items-center gap-1">
<div ref="dataMenuRef" class="relative shrink-0">
<div v-if="readWorkspaceMode() !== 'quick'" ref="dataMenuRef" class="relative shrink-0">
<Button variant="outline" size="sm"
class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting"
@ -2179,7 +2175,7 @@ watch(
<input ref="importFileRef" type="file" accept=".zw" class="hidden" @change="importData" />
</div>
<Button variant="outline" size="sm" class="app-toolbar-btn shrink-0 cursor-pointer"
<Button v-if="readWorkspaceMode() !== 'quick'" variant="outline" size="sm" class="app-toolbar-btn shrink-0 cursor-pointer"
:disabled="isResetting"
@click="openUserGuide(0)">
<CircleHelp class="h-4 w-4 mr-1" />

View File

@ -0,0 +1,79 @@
import type { IHeaderComp, IHeaderParams } from 'ag-grid-community'
export type ResetHeaderParams = IHeaderParams & {
onReset?: () => void | Promise<void>
resetTitle?: string
}
export class AgGridResetHeader implements IHeaderComp {
private params!: ResetHeaderParams
private eGui!: HTMLDivElement
private eLabel!: HTMLSpanElement
private eButton!: HTMLButtonElement
private onButtonClick = (event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
void this.params.onReset?.()
}
init(params: ResetHeaderParams) {
this.params = params
const eGui = document.createElement('div')
eGui.style.display = 'flex'
eGui.style.alignItems = 'center'
eGui.style.justifyContent = 'space-between'
eGui.style.gap = '6px'
eGui.style.width = '100%'
const eLabel = document.createElement('span')
eLabel.style.flex = '1'
eLabel.style.minWidth = '0'
eLabel.style.whiteSpace = 'normal'
eLabel.style.lineHeight = '1.2'
const eButton = document.createElement('button')
eButton.type = 'button'
eButton.textContent = '↻'
eButton.title = params.resetTitle || '恢复默认值'
eButton.setAttribute('aria-label', params.resetTitle || '恢复默认值')
eButton.style.display = 'inline-flex'
eButton.style.alignItems = 'center'
eButton.style.justifyContent = 'center'
eButton.style.width = '18px'
eButton.style.height = '18px'
eButton.style.border = '1px solid #d1d5db'
eButton.style.borderRadius = '999px'
eButton.style.background = '#fff'
eButton.style.color = '#4b5563'
eButton.style.cursor = 'pointer'
eButton.style.fontSize = '12px'
eButton.style.lineHeight = '1'
eButton.style.flex = '0 0 auto'
eButton.addEventListener('click', this.onButtonClick)
eGui.append(eLabel, eButton)
this.eGui = eGui
this.eLabel = eLabel
this.eButton = eButton
this.refresh(params)
}
getGui() {
return this.eGui
}
refresh(params: ResetHeaderParams) {
this.params = params
this.eLabel.textContent = params.displayName || params.column?.getColDef().headerName || ''
this.eButton.title = params.resetTitle || '恢复默认值'
this.eButton.setAttribute('aria-label', params.resetTitle || '恢复默认值')
this.eButton.style.visibility = params.onReset ? 'visible' : 'hidden'
return true
}
destroy() {
this.eButton?.removeEventListener('click', this.onButtonClick)
}
}

View File

@ -37,6 +37,8 @@ export const gridOptions: GridOptions = {
suppressContextMenu: false,
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
enterNavigatesVertically: true,
enterNavigatesVerticallyAfterEdit: true,
// rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。
getRowId: params => {
const id = params.data?.id

View File

@ -76,7 +76,6 @@ export const getScaleBudgetFeeSplit = (params: {
const roundedBenchmarkBudget = roundTo(addNumbers(benchmarkBudgetBasic, benchmarkBudgetOptional), 2)
const basic = roundTo(toDecimal(benchmarkBudgetBasic).mul(multiplier), 2)
const optional = roundTo(toDecimal(benchmarkBudgetOptional).mul(multiplier), 2)
return {
basic,
optional,

View File

@ -1,6 +1,7 @@
import { getMajorDictById, getMajorIdAliasMap, getServiceDictById } from '@/sql'
import { toFiniteNumberOrNull } from '@/lib/number'
import { useKvStore } from '@/pinia/kv'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
@ -49,13 +50,23 @@ const getKvStoreSafely = () => {
}
}
const getZxFwPricingStoreSafely = () => {
try {
return useZxFwPricingStore()
} catch {
return null
}
}
const loadFactorMap = async (
storageKey: string,
dict: FactorDict,
aliases?: Map<string, string>
): Promise<Map<string, number | null>> => {
const zxFwPricingStore = getZxFwPricingStoreSafely()
const kvStore = getKvStoreSafely()
const data = kvStore ? await kvStore.getItem<XmFactorState>(storageKey) : null
const piniaData = zxFwPricingStore ? await zxFwPricingStore.loadKeyState<XmFactorState>(storageKey) : null
const data = piniaData ?? (kvStore ? await kvStore.getItem<XmFactorState>(storageKey) : null)
const map = buildStandardFactorMap(dict)
for (const row of data?.detailRows || []) {
if (!row?.id) continue

View File

@ -77,7 +77,7 @@ export const majorList = {
0: { code: 'E1', name: '交通运输工程通用专业', hideInIndustrySelector: true, maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 1, hasCost: false, hasArea: false },
1: { code: 'E1-1', name: '征地(用海)补偿', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目征地(用海)补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 2, hasCost: true, hasArea: true },
2: { code: 'E1-2', name: '拆迁补偿', maxCoe: null, minCoe: null, defCoe: 2.5, desc: '适用于交通建设项目拆迁补偿的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 3, hasCost: true, hasArea: true },
3: { code: 'E1-3', name: '迁改工程', maxCoe: null, minCoe: null, defCoe: 2, desc: '适用于交通建设项目迁改工程的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 4, hasCost: true, hasArea: false },
3: { code: 'E1-3', name: '迁改工程', quickLabel: '迁改工程等费用', maxCoe: null, minCoe: null, defCoe: 2, desc: '适用于交通建设项目迁改工程的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)、合同(工程)结算和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 4, hasCost: true, hasArea: false },
4: { code: 'E1-4', name: '工程建设其他费', maxCoe: null, minCoe: null, defCoe: 1, desc: '适用于交通建设项目的工程建设其他费的施工图预算、招标工程量清单及清单预算(或最高投标限价)、清理概算(仅限铁路工程)和造价鉴定、计算工程量、工程变更费用咨询、工程成本测(核)算', isRoad: true, isRailway: true, isWaterway: true, order: 5, hasCost: true, hasArea: false },
5: { code: 'E1-5', name: '预备费', maxCoe: null, minCoe: null, defCoe: 1, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 6, hasCost: true, hasArea: false },
6: { code: 'E1-6', name: '建设期贷款利息', maxCoe: null, minCoe: null, defCoe: 1, desc: '', isRoad: true, isRailway: true, isWaterway: true, order: 7, hasCost: true, hasArea: false },
@ -116,9 +116,9 @@ export const majorList = {
export const serviceList = {
0: { code: 'D1', name: '全过程造价咨询', maxCoe: null, minCoe: null, defCoe: 1, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 1, scale: true, onlyCostScale: true, amount: false, workDay: true },
1: { code: 'D2', name: '分阶段造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 2, scale: null, onlyCostScale: null, amount: null, workDay: null },
2: { code: 'D2-1', name: '前期阶段造价咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 3, scale: true, onlyCostScale: true, amount: false, workDay: true },
3: { code: 'D2-2-1', name: '实施阶段造价咨询(公路、水运)', maxCoe: null, minCoe: null, defCoe: 0.55, desc: '本系数适用于公路和水运工程。', isRoad: true, isRailway: false, isWaterway: true, mutiple: false, order: 4, scale: true, onlyCostScale: true, amount: false, workDay: true },
4: { code: 'D2-2-2', name: '实施阶段造价咨询(铁路)', maxCoe: null, minCoe: null, defCoe: 0.6, desc: '本系数适用于铁路工程。', isRoad: false, isRailway: true, isWaterway: false, mutiple: false, order: 5, scale: true, onlyCostScale: true, amount: false, workDay: true },
2: { code: 'D2-1', name: '前期阶段造价咨询', quickLabel: '项目前期阶段造价咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 3, scale: true, onlyCostScale: true, amount: false, workDay: true },
3: { code: 'D2-2-1', name: '实施阶段造价咨询(公路、水运)', quickLabel: '项目实施阶段造价咨询', maxCoe: null, minCoe: null, defCoe: 0.55, desc: '本系数适用于公路和水运工程。', isRoad: true, isRailway: false, isWaterway: true, mutiple: false, order: 4, scale: true, onlyCostScale: true, amount: false, workDay: true },
4: { code: 'D2-2-2', name: '实施阶段造价咨询(铁路)', quickLabel: '项目实施阶段造价咨询', maxCoe: null, minCoe: null, defCoe: 0.6, desc: '本系数适用于铁路工程。', isRoad: false, isRailway: true, isWaterway: false, mutiple: false, order: 5, scale: true, onlyCostScale: true, amount: false, workDay: true },
5: { code: 'D3', name: '基本造价咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 6, scale: null, onlyCostScale: null, amount: null, workDay: null },
6: { code: 'D3-1', name: '投资估算', maxCoe: null, minCoe: null, defCoe: 0.1, desc: '委托同一咨询人同时负责D3-1和D3-2时D3-1和D3-2的合计调整系数为0.25。', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 7, scale: true, onlyCostScale: true, amount: false, workDay: true },
7: { code: 'D3-2', name: '设计概算', maxCoe: null, minCoe: null, defCoe: 0.2, desc: '', isRoad: true, isRailway: true, isWaterway: true, mutiple: false, order: 8, scale: true, onlyCostScale: true, amount: false, workDay: true },
@ -674,10 +674,159 @@ export const getServiceDictItemById = (id: string | number): DictItem | undefine
return dict[key]
}
export type QuickCalcOption = {
label: string
code: string | Record<string, string>
}
// 判断数值是否命中分段区间:(staLine, endLine]。
export type QuickCalcGroup = {
key: string
label: string
hint: string
items: QuickCalcOption[]
rows: string[][]
industryId?: string
}
const getQuickDictLabel = (item: DictItem | undefined, fallback = '') =>
String(item?.quickLabel || item?.name || fallback)
const createQuickOptionByServiceCode = (
code: string,
override?: Partial<QuickCalcOption> & { label?: string }
): QuickCalcOption => {
const entry = getServiceDictEntries().find(item => String(item.item?.code || '') === code)
return {
label: override?.label || getQuickDictLabel(entry?.item, code),
code: override?.code || code
}
}
const createQuickOptionByMajorCode = (
code: string,
override?: Partial<QuickCalcOption> & { label?: string }
): QuickCalcOption => {
const entry = getMajorDictEntries().find(item => String(item.item?.code || '') === code)
return {
label: override?.label || getQuickDictLabel(entry?.item, code),
code: override?.code || code
}
}
export const getQuickCalcGroups = (): QuickCalcGroup[] => [
{
key: 'consult',
label: '咨询类别(常用)',
hint: '先选择咨询类别,再补规模和预算参数。',
items: [
createQuickOptionByServiceCode('D1'),
createQuickOptionByServiceCode('D2-1'),
createQuickOptionByServiceCode('D2-2-1', { code: { '0': 'D2-2-1', '1': 'D2-2-2', '2': 'D2-2-1' } }),
createQuickOptionByServiceCode('D3-1'),
createQuickOptionByServiceCode('D3-2'),
createQuickOptionByServiceCode('D3-3'),
createQuickOptionByServiceCode('D3-4'),
createQuickOptionByServiceCode('D3-5'),
createQuickOptionByServiceCode('D3-6-1', { code: { '0': 'D3-6-1', '1': 'D3-6-2', '2': 'D3-6-1' } }),
createQuickOptionByServiceCode('D3-7'),
createQuickOptionByServiceCode('D4-6'),
createQuickOptionByServiceCode('D4-7'),
createQuickOptionByServiceCode('D4-8'),
createQuickOptionByServiceCode('D4-9'),
createQuickOptionByServiceCode('D4-10'),
createQuickOptionByServiceCode('D4-11'),
createQuickOptionByServiceCode('D4-12')
],
rows: [
['全过程造价咨询', '项目前期阶段造价咨询', '项目实施阶段造价咨询'],
['投资估算', '设计概算', '施工图预算', '招标工程量清单及清单预算(或最高投标限价)', '清理概算(仅限铁路)', '合同(工程)结算', '竣工决算'],
['造价鉴定', '工程成本测算', '工程成本核算', '计算工程量', '工程变更费用咨询', '调整估算', '调整概算']
]
},
{
key: 'general',
label: '通用专业',
hint: '跨行业共用的补偿与其他费用专业。',
items: [
createQuickOptionByMajorCode('E1-1'),
createQuickOptionByMajorCode('E1-2'),
createQuickOptionByMajorCode('E1-3'),
createQuickOptionByMajorCode('E1-4')
],
rows: [['征地(用海)补偿', '拆迁补偿', '迁改工程等费用', '工程建设其他费']]
},
{
key: 'road',
label: '公路工程专业',
hint: '首页行业为公路工程时默认展示。',
industryId: '0',
items: [
createQuickOptionByMajorCode('E2'),
createQuickOptionByMajorCode('E2-1'),
createQuickOptionByMajorCode('E2-2'),
createQuickOptionByMajorCode('E2-3'),
createQuickOptionByMajorCode('E2-4'),
createQuickOptionByMajorCode('E2-5'),
createQuickOptionByMajorCode('E2-6'),
createQuickOptionByMajorCode('E2-7'),
createQuickOptionByMajorCode('E2-8'),
createQuickOptionByMajorCode('E2-9'),
createQuickOptionByMajorCode('E2-10')
],
rows: [
['建设工程项目'],
['临时工程', '路基工程', '路面工程', '桥涵工程', '隧道工程', '交叉工程'],
['机电工程', '交通安全设施工程', '绿化及环境保护工程', '房建工程']
]
},
{
key: 'railway',
label: '铁路工程专业',
hint: '首页行业为铁路工程时默认展示。',
industryId: '1',
items: [
createQuickOptionByMajorCode('E3'),
createQuickOptionByMajorCode('E3-1'),
createQuickOptionByMajorCode('E3-2'),
createQuickOptionByMajorCode('E3-3'),
createQuickOptionByMajorCode('E3-4'),
createQuickOptionByMajorCode('E3-5'),
createQuickOptionByMajorCode('E3-6'),
createQuickOptionByMajorCode('E3-7'),
createQuickOptionByMajorCode('E3-8'),
createQuickOptionByMajorCode('E3-9')
],
rows: [
['建设工程项目'],
['大型临时设施和过渡工程', '路基工程', '桥涵工程', '隧道及明洞工程', '轨道工程'],
['通信、信号、信息及灾害监测工程', '电力及电力牵引供电工程', '房建工程(房屋建筑及附属工程)', '装饰装修工程']
]
},
{
key: 'waterway',
label: '水运工程专业',
hint: '首页行业为水运工程时默认展示。',
industryId: '2',
items: [
createQuickOptionByMajorCode('E4'),
createQuickOptionByMajorCode('E4-1'),
createQuickOptionByMajorCode('E4-2'),
createQuickOptionByMajorCode('E4-3'),
createQuickOptionByMajorCode('E4-4'),
createQuickOptionByMajorCode('E4-5', { label: '房建工程(房屋建筑及附属工程)' })
],
rows: [
['建设工程项目'],
['临时工程', '土建工程'],
['机电与金属结构工程', '设备工程', '房建工程(房屋建筑及附属工程)']
]
}
]
// 判断数值是否命中分段区间:默认 (staLine, endLine],但首段 0 需要包含 0。
const inRange = (sv: number, staLine: number, endLine: number | null) =>
staLine < sv && (endLine == null || sv <= endLine)
(staLine === 0 ? staLine <= sv : staLine < sv) && (endLine == null || sv <= endLine)
// 按分段参数计算基础费用。
const calcScaleFee = (params: {
@ -730,8 +879,9 @@ export function getBasicFeeFromScale(
scaleValue: unknown,
scaleType: 'cost' | 'area'
): BasicFeeFromScaleResult | null {
if (scaleValue == null || scaleValue === '') return null
const sv = Number(scaleValue)
if (!Number.isFinite(sv) || sv <= 0) return null
if (!Number.isFinite(sv) || sv < 0) return null
if (scaleType === 'cost') {
const targetRange = costScaleCal.find(f => inRange(sv, f.staLine, f.endLine))

View File

@ -365,6 +365,15 @@ input[inputmode='numeric'] {
font-style: italic;
}
/* Keep right-aligned editable placeholders right-aligned after refresh/edit cycles. */
.xmMx .ag-cell.ag-right-aligned-cell.editable-cell-empty .ag-cell-wrapper {
justify-content: flex-end;
}
.xmMx .ag-cell.ag-right-aligned-cell.editable-cell-empty .ag-cell-value {
text-align: right !important;
}
/* Web adaptive typography baseline: tablet / laptop / 1080p / 2K / 4K */
@media (max-width: 1024px) {
html {

View File

@ -1 +1 @@
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectworkspace.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ht/ht.vue","./src/components/ht/htadditionalworkfee.vue","./src/components/ht/htbaseinfo.vue","./src/components/ht/htconsultcategoryfactor.vue","./src/components/ht/htcontractsummary.vue","./src/components/ht/htfeeratemethodform.vue","./src/components/ht/htmajorfactor.vue","./src/components/ht/htreservefee.vue","./src/components/ht/htcard.vue","./src/components/ht/htinfo.vue","./src/components/ht/zxfw.vue","./src/components/pricing/hourlypricingpane.vue","./src/components/pricing/investmentscalepricingpane.vue","./src/components/pricing/landscalepricingpane.vue","./src/components/pricing/workloadpricingpane.vue","./src/components/shared/hourlyfeegrid.vue","./src/components/shared/htfeegrid.vue","./src/components/shared/htfeemethodgrid.vue","./src/components/shared/methodunavailablenotice.vue","./src/components/shared/servicecheckboxselector.vue","./src/components/shared/workcontentgrid.vue","./src/components/shared/xmfactorgrid.vue","./src/components/shared/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/homeentryview.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/zxfwview.vue","./src/components/xm/xmconsultcategoryfactor.vue","./src/components/xm/xmmajorfactor.vue","./src/components/xm/info.vue","./src/components/xm/xmcard.vue","./src/components/xm/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/aggridresetheader.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectworkspace.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ht/ht.vue","./src/components/ht/htadditionalworkfee.vue","./src/components/ht/htbaseinfo.vue","./src/components/ht/htconsultcategoryfactor.vue","./src/components/ht/htcontractsummary.vue","./src/components/ht/htfeeratemethodform.vue","./src/components/ht/htmajorfactor.vue","./src/components/ht/htreservefee.vue","./src/components/ht/htcard.vue","./src/components/ht/htinfo.vue","./src/components/ht/zxfw.vue","./src/components/pricing/hourlypricingpane.vue","./src/components/pricing/investmentscalepricingpane.vue","./src/components/pricing/landscalepricingpane.vue","./src/components/pricing/workloadpricingpane.vue","./src/components/shared/hourlyfeegrid.vue","./src/components/shared/htfeegrid.vue","./src/components/shared/htfeemethodgrid.vue","./src/components/shared/methodunavailablenotice.vue","./src/components/shared/servicecheckboxselector.vue","./src/components/shared/workcontentgrid.vue","./src/components/shared/xmfactorgrid.vue","./src/components/shared/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/homeentryview.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/quickcalcworkbenchview.vue","./src/components/views/zxfwview.vue","./src/components/xm/xmconsultcategoryfactor.vue","./src/components/xm/xmmajorfactor.vue","./src/components/xm/info.vue","./src/components/xm/xmcard.vue","./src/components/xm/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}