fix someone

This commit is contained in:
wintsa 2026-03-07 11:47:07 +08:00
parent c482faacbf
commit ad4e9cdee0
17 changed files with 1084 additions and 311 deletions

View File

@ -0,0 +1,278 @@
<script setup lang="ts">
import { onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import localforage from 'localforage'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { parseNumberOrNull } from '@/lib/number'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { roundTo, toDecimal } from '@/lib/decimal'
interface FeeRow {
id: string
feeItem: string
unit: string
quantity: number | null
unitPrice: number | null
budgetFee: number | null
remark: string
}
interface FeeGridState {
detailRows: FeeRow[]
}
const props = defineProps<{
title: string
storageKey: string
}>()
const createRowId = () => `fee-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
const createDefaultRow = (): FeeRow => ({
id: createRowId(),
feeItem: '',
unit: '',
quantity: null,
unitPrice: null,
budgetFee: null,
remark: ''
})
const detailRows = ref<FeeRow[]>([createDefaultRow()])
const gridApi = ref<GridApi<FeeRow> | null>(null)
const formatEditableText = (params: any) => {
if (params.value == null || params.value === '') return '点击输入'
return String(params.value)
}
const formatEditableQuantity = (params: any) => {
if (params.value == null || params.value === '') return '点击输入'
return formatThousandsFlexible(params.value, 4)
}
const formatEditableUnitPrice = (params: any) => {
if (params.value == null || params.value === '') return '点击输入'
return formatThousands(params.value, 2)
}
const formatReadonlyBudgetFee = (params: any) => {
if (params.value == null || params.value === '') return ''
return formatThousands(params.value, 2)
}
const syncComputedValuesToRows = () => {
for (const row of detailRows.value) {
if (row.quantity == null || row.unitPrice == null) {
row.budgetFee = null
continue
}
row.budgetFee = roundTo(toDecimal(row.quantity).mul(row.unitPrice), 2)
}
}
const mergeWithStoredRows = (rowsFromDb: unknown): FeeRow[] => {
if (!Array.isArray(rowsFromDb) || rowsFromDb.length === 0) {
return [createDefaultRow()]
}
const rows: FeeRow[] = rowsFromDb.map(item => {
const row = item as Partial<FeeRow>
return {
id: typeof row.id === 'string' && row.id ? row.id : createRowId(),
feeItem: typeof row.feeItem === 'string' ? row.feeItem : '',
unit: typeof row.unit === 'string' ? row.unit : '',
quantity: typeof row.quantity === 'number' ? row.quantity : null,
unitPrice: typeof row.unitPrice === 'number' ? row.unitPrice : null,
budgetFee: typeof row.budgetFee === 'number' ? row.budgetFee : null,
remark: typeof row.remark === 'string' ? row.remark : ''
}
})
return rows.length > 0 ? rows : [createDefaultRow()]
}
const buildPersistDetailRows = () => {
syncComputedValuesToRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => {
try {
const payload: FeeGridState = {
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
}
await localforage.setItem(props.storageKey, payload)
} catch (error) {
console.error('saveToIndexedDB failed:', error)
}
}
const loadFromIndexedDB = async () => {
try {
const data = await localforage.getItem<FeeGridState>(props.storageKey)
detailRows.value = mergeWithStoredRows(data?.detailRows)
syncComputedValuesToRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = [createDefaultRow()]
syncComputedValuesToRows()
}
}
const columnDefs: ColDef<FeeRow>[] = [
{
headerName: '费用项',
field: 'feeItem',
minWidth: 140,
flex: 1.4,
editable: true,
valueFormatter: formatEditableText,
cellClass: 'editable-cell-line',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '单位',
field: 'unit',
minWidth: 90,
flex: 0.9,
editable: true,
valueFormatter: formatEditableText,
cellClass: 'editable-cell-line',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '数量',
field: 'quantity',
minWidth: 100,
flex: 1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: true,
valueParser: params => parseNumberOrNull(params.newValue, { precision: 4 }),
valueFormatter: formatEditableQuantity,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '单价(元)',
field: 'unitPrice',
minWidth: 120,
flex: 1.1,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell editable-cell-line',
editable: true,
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableUnitPrice,
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
},
{
headerName: '预算费用(元)',
field: 'budgetFee',
minWidth: 130,
flex: 1.2,
headerClass: 'ag-right-aligned-header',
cellClass: 'ag-right-aligned-cell',
editable: false,
valueFormatter: formatReadonlyBudgetFee
},
{
headerName: '说明',
field: 'remark',
minWidth: 170,
flex: 2,
editable: true,
cellEditor: 'agLargeTextCellEditor',
wrapText: true,
autoHeight: true,
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
valueFormatter: formatEditableText,
cellClass: 'editable-cell-line remark-wrap-cell',
cellClassRules: {
'editable-cell-empty': params => params.value == null || params.value === ''
}
}
]
const detailGridOptions: GridOptions<FeeRow> = {
...gridOptions,
treeData: false
}
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
gridApi.value = event.api
}
const handleCellValueChanged = () => {
syncComputedValuesToRows()
gridApi.value?.refreshCells({ columns: ['budgetFee'], force: true })
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => {
void saveToIndexedDB()
}, 300)
}
onMounted(async () => {
await loadFromIndexedDB()
})
onActivated(() => {
void loadFromIndexedDB()
})
watch(
() => props.storageKey,
() => {
void loadFromIndexedDB()
}
)
onBeforeUnmount(() => {
if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridApi.value = null
void saveToIndexedDB()
})
</script>
<template>
<div class="h-full min-h-0 flex flex-col">
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col">
<div class="flex items-center justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">{{ title }}</h3>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue
:style="{ height: '100%' }"
:rowData="detailRows"
:columnDefs="columnDefs"
:gridOptions="detailGridOptions"
:theme="myTheme"
:treeData="false"
:localeText="AG_GRID_LOCALE_CN"
:tooltipShowDelay="500"
:headerHeight="50"
:suppressColumnVirtualisation="true"
:suppressRowVirtualisation="true"
:cellSelection="{ handle: { mode: 'range' } }"
:enableClipboard="true"
:undoRedoCellEditing="true"
:undoRedoCellEditingLimit="20"
@grid-ready="handleGridReady"
@cell-value-changed="handleCellValueChanged"
/>
</div>
</div>
</div>
</template>

View File

@ -57,7 +57,7 @@ const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
const SERVICE_KEY_PREFIX = 'zxFW-' const SERVICE_KEY_PREFIX = 'zxFW-'
const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-' const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-' const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-'] const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-']
const PROJECT_INFO_KEY = 'xm-base-info-v1' const PROJECT_INFO_KEY = 'xm-base-info-v1'

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
const props = defineProps<{
contractId: string
}>()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-additional-work`)
</script>
<template>
<HtFeeGrid title="附加工作费" :storageKey="STORAGE_KEY" />
</template>

View File

@ -0,0 +1,14 @@
<script setup lang="ts">
import { computed } from 'vue'
import HtFeeGrid from '@/components/common/HtFeeGrid.vue'
const props = defineProps<{
contractId: string
}>()
const STORAGE_KEY = computed(() => `htExtraFee-${props.contractId}-reserve`)
</script>
<template>
<HtFeeGrid title="预备费" :storageKey="STORAGE_KEY" />
</template>

View File

@ -89,11 +89,43 @@ const majorFactorView = markRaw(
}) })
); );
const additionalWorkFeeView = markRaw(
defineComponent({
name: 'HtAdditionalWorkFeeWithProps',
setup() {
const AsyncHtAdditionalWorkFee = defineAsyncComponent({
loader: () => import('@/components/views/HtAdditionalWorkFee.vue'),
onError: (err) => {
console.error('加载 HtAdditionalWorkFee 组件失败:', err);
}
});
return () => h(AsyncHtAdditionalWorkFee, { contractId: props.contractId });
}
})
);
const reserveFeeView = markRaw(
defineComponent({
name: 'HtReserveFeeWithProps',
setup() {
const AsyncHtReserveFee = defineAsyncComponent({
loader: () => import('@/components/views/HtReserveFee.vue'),
onError: (err) => {
console.error('加载 HtReserveFee 组件失败:', err);
}
});
return () => h(AsyncHtReserveFee, { contractId: props.contractId });
}
})
);
// 4. // 4.
const xmCategories: XmCategoryItem[] = [ const xmCategories: XmCategoryItem[] = [
{ key: 'info', label: '规模信息', component: htView }, { key: 'info', label: '规模信息', component: htView },
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView }, { key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }, { key: 'major-factor', label: '工程专业系数', component: majorFactorView },
{ key: 'additional-work-fee', label: '附加工作费', component: additionalWorkFeeView },
{ key: 'reserve-fee', label: '预备费', component: reserveFeeView },
{ key: 'contract', label: '咨询服务', component: zxfwView }, { key: 'contract', label: '咨询服务', component: zxfwView },
]; ];

View File

@ -43,6 +43,13 @@ const DB_KEY = 'xm-base-info-v1'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务' const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择' const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed' const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
const getTodayDateString = () => {
const now = new Date()
const year = String(now.getFullYear())
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
const isProjectInitialized = ref(false) const isProjectInitialized = ref(false)
const showCreateDialog = ref(false) const showCreateDialog = ref(false)
@ -53,7 +60,7 @@ const projectIndustry = ref('')
const preparedBy = ref('') const preparedBy = ref('')
const reviewedBy = ref('') const reviewedBy = ref('')
const preparedCompany = ref('') const preparedCompany = ref('')
const preparedDate = ref('') const preparedDate = ref(getTodayDateString())
const preparedDatePickerValue = ref<any>(undefined) const preparedDatePickerValue = ref<any>(undefined)
const normalizeDateString = (value: unknown): string => { const normalizeDateString = (value: unknown): string => {
@ -123,7 +130,7 @@ const loadFromIndexedDB = async () => {
preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : '' preparedBy.value = typeof data.preparedBy === 'string' ? data.preparedBy : ''
reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : '' reviewedBy.value = typeof data.reviewedBy === 'string' ? data.reviewedBy : ''
preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : '' preparedCompany.value = typeof data.preparedCompany === 'string' ? data.preparedCompany : ''
preparedDate.value = normalizeDateString(data.preparedDate) preparedDate.value = normalizeDateString(data.preparedDate) || getTodayDateString()
syncPreparedDatePickerFromString() syncPreparedDatePickerFromString()
return return
} }
@ -134,7 +141,7 @@ const loadFromIndexedDB = async () => {
preparedBy.value = '' preparedBy.value = ''
reviewedBy.value = '' reviewedBy.value = ''
preparedCompany.value = '' preparedCompany.value = ''
preparedDate.value = '' preparedDate.value = getTodayDateString()
syncPreparedDatePickerFromString() syncPreparedDatePickerFromString()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
@ -143,7 +150,7 @@ const loadFromIndexedDB = async () => {
preparedBy.value = '' preparedBy.value = ''
reviewedBy.value = '' reviewedBy.value = ''
preparedCompany.value = '' preparedCompany.value = ''
preparedDate.value = '' preparedDate.value = getTodayDateString()
syncPreparedDatePickerFromString() syncPreparedDatePickerFromString()
} }
} }
@ -182,7 +189,7 @@ const createProject = async () => {
preparedBy.value = '' preparedBy.value = ''
reviewedBy.value = '' reviewedBy.value = ''
preparedCompany.value = '' preparedCompany.value = ''
preparedDate.value = '' preparedDate.value = getTodayDateString()
syncPreparedDatePickerFromString() syncPreparedDatePickerFromString()
isProjectInitialized.value = true isProjectInitialized.value = true
showCreateDialog.value = false showCreateDialog.value = false
@ -320,7 +327,7 @@ onMounted(async () => {
</template> </template>
</div> </div>
<DatePickerTrigger as-child> <DatePickerTrigger as-child>
<button type="button" class="inline-flex h-6 w-6 items-center justify-center text-muted-foreground"> <button type="button" class="cursor-pointer inline-flex h-6 w-6 items-center justify-center text-muted-foreground">
<CalendarIcon class="h-4 w-4" /> <CalendarIcon class="h-4 w-4" />
</button> </button>
</DatePickerTrigger> </DatePickerTrigger>
@ -333,7 +340,7 @@ onMounted(async () => {
<DatePickerPrev as-child> <DatePickerPrev as-child>
<button <button
type="button" type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted" class=" cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
> >
</button> </button>
@ -342,7 +349,7 @@ onMounted(async () => {
<DatePickerNext as-child> <DatePickerNext as-child>
<button <button
type="button" type="button"
class="inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted" class="cursor-potiner inline-flex h-9 w-9 items-center justify-center rounded-md border text-sm transition hover:bg-muted"
> >
</button> </button>
@ -380,7 +387,7 @@ onMounted(async () => {
<DatePickerCellTrigger <DatePickerCellTrigger
:day="dateValue" :day="dateValue"
:month="month.value" :month="month.value"
class="h-full w-full rounded-md border border-transparent bg-transparent text-base outline-none transition hover:bg-muted data-[outside-view]:text-muted-foreground/40 data-[selected]:border-primary data-[selected]:bg-transparent data-[selected]:text-foreground data-[disabled]:opacity-40 data-[unavailable]:text-muted-foreground/40" class="cursor-pointer h-full w-full rounded-md border border-transparent bg-transparent text-base outline-none transition hover:bg-muted data-[outside-view]:text-muted-foreground/40 data-[selected]:border-primary data-[selected]:bg-transparent data-[selected]:text-foreground data-[disabled]:opacity-40 data-[unavailable]:text-muted-foreground/40"
> >
{{ dateValue.day }} {{ dateValue.day }}
</DatePickerCellTrigger> </DatePickerCellTrigger>
@ -394,10 +401,11 @@ onMounted(async () => {
<DatePickerClose as-child> <DatePickerClose as-child>
<button <button
type="button" type="button"
class="h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted" class="cursor-pointer h-8 rounded-md border px-3 text-xs text-foreground transition hover:bg-muted mr-2"
> >
关闭 确认
</button> </button>
</DatePickerClose> </DatePickerClose>
</div> </div>
</DatePickerContent> </DatePickerContent>

View File

@ -434,7 +434,7 @@ const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void saveToIndexedDB()
}, 1000) }, 300)
} }
onMounted(async () => { onMounted(async () => {

View File

@ -55,10 +55,14 @@ interface DetailRow {
benchmarkBudget: number | null benchmarkBudget: number | null
benchmarkBudgetBasic: number | null benchmarkBudgetBasic: number | null
benchmarkBudgetOptional: number | null benchmarkBudgetOptional: number | null
benchmarkBudgetBasicChecked: boolean
benchmarkBudgetOptionalChecked: boolean
basicFormula: string basicFormula: string
optionalFormula: string optionalFormula: string
consultCategoryFactor: number | null consultCategoryFactor: number | null
majorFactor: number | null majorFactor: number | null
workStageFactor: number | null
workRatio: number | null
budgetFee: number | null budgetFee: number | null
budgetFeeBasic: number | null budgetFeeBasic: number | null
budgetFeeOptional: number | null budgetFeeOptional: number | null
@ -80,6 +84,8 @@ const props = defineProps<{
}>() }>()
const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `tzGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`) const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
const BASE_INFO_KEY = 'xm-base-info-v1' const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('') const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
@ -97,8 +103,8 @@ const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.
const loadFactorDefaults = async () => { const loadFactorDefaults = async () => {
const [consultMap, majorMap] = await Promise.all([ const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(), loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value),
loadMajorFactorMap() loadMajorFactorMap(HT_MAJOR_FACTOR_KEY.value)
]) ])
consultCategoryFactorMap.value = consultMap consultCategoryFactorMap.value = consultMap
majorFactorMap.value = majorMap majorFactorMap.value = majorMap
@ -216,10 +222,14 @@ const buildDefaultRows = (): DetailRow[] => {
benchmarkBudget: null, benchmarkBudget: null,
benchmarkBudgetBasic: null, benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null, benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '', basicFormula: '',
optionalFormula: '', optionalFormula: '',
consultCategoryFactor: null, consultCategoryFactor: null,
majorFactor: null, majorFactor: null,
workStageFactor: 1,
workRatio: 100,
budgetFee: null, budgetFee: null,
budgetFeeBasic: null, budgetFeeBasic: null,
budgetFeeOptional: null, budgetFeeOptional: null,
@ -239,10 +249,14 @@ type SourceRow = Pick<DetailRow, 'id'> &
| 'benchmarkBudget' | 'benchmarkBudget'
| 'benchmarkBudgetBasic' | 'benchmarkBudgetBasic'
| 'benchmarkBudgetOptional' | 'benchmarkBudgetOptional'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'basicFormula' | 'basicFormula'
| 'optionalFormula' | 'optionalFormula'
| 'consultCategoryFactor' | 'consultCategoryFactor'
| 'majorFactor' | 'majorFactor'
| 'workStageFactor'
| 'workRatio'
| 'budgetFee' | 'budgetFee'
| 'budgetFeeBasic' | 'budgetFeeBasic'
| 'budgetFeeOptional' | 'budgetFeeOptional'
@ -277,6 +291,10 @@ const mergeWithDictRows = (
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null, benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null, benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : 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 : '', basicFormula: typeof fromDb.basicFormula === 'string' ? fromDb.basicFormula : '',
optionalFormula: typeof fromDb.optionalFormula === 'string' ? fromDb.optionalFormula : '', optionalFormula: typeof fromDb.optionalFormula === 'string' ? fromDb.optionalFormula : '',
consultCategoryFactor: consultCategoryFactor:
@ -295,6 +313,8 @@ const mergeWithDictRows = (
: hasMajorFactor : hasMajorFactor
? null ? null
: getDefaultMajorFactorById(row.id), : getDefaultMajorFactorById(row.id),
workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor,
workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null, budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
budgetFeeBasic: typeof fromDb.budgetFeeBasic === 'number' ? fromDb.budgetFeeBasic : null, budgetFeeBasic: typeof fromDb.budgetFeeBasic === 'number' ? fromDb.budgetFeeBasic : null,
budgetFeeOptional: typeof fromDb.budgetFeeOptional === 'number' ? fromDb.budgetFeeOptional : null, budgetFeeOptional: typeof fromDb.budgetFeeOptional === 'number' ? fromDb.budgetFeeOptional : null,
@ -335,10 +355,46 @@ const formatReadonlyMoney = (params: any) => {
return formatThousands(roundTo(params.value, 2)) return formatThousands(roundTo(params.value, 2))
} }
type BudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => {
const valueText = formatReadonlyMoney(params)
const hasValue = params.value != null && params.value !== ''
if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
return valueText
}
const wrapper = document.createElement('div')
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'flex-end'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', () => {
params.data[checkField] = checkbox.checked
handleCellValueChanged()
})
const valueSpan = document.createElement('span')
valueSpan.textContent = valueText
wrapper.append(checkbox, valueSpan)
return wrapper
}
const getBenchmarkBudgetSplitByAmount = (row?: Pick<DetailRow, 'amount'>) => const getBenchmarkBudgetSplitByAmount = (row?: Pick<DetailRow, 'amount'>) =>
getBenchmarkBudgetSplitByScale(row?.amount, 'cost') getBenchmarkBudgetSplitByScale(row?.amount, 'cost')
const getBudgetFee = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor'>) => { const getBudgetFee = (
row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
) => {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByAmount(row) const benchmarkBudgetSplit = getBenchmarkBudgetSplitByAmount(row)
if (!benchmarkBudgetSplit) return null if (!benchmarkBudgetSplit) return null
@ -346,19 +402,25 @@ const getBudgetFee = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultC
benchmarkBudgetBasic: benchmarkBudgetSplit.basic, benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional, benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor, majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
}) })
return splitBudgetFee ? splitBudgetFee.total : null return splitBudgetFee ? splitBudgetFee.total : null
} }
const getBudgetFeeSplit = (row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor'>) => { const getBudgetFeeSplit = (
row?: Pick<DetailRow, 'amount' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
) => {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByAmount(row) const benchmarkBudgetSplit = getBenchmarkBudgetSplitByAmount(row)
if (!benchmarkBudgetSplit) return null if (!benchmarkBudgetSplit) return null
return getScaleBudgetFeeSplit({ return getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic, benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional, benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor, majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
}) })
} }
@ -367,7 +429,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerName: '造价金额(万元)', headerName: '造价金额(万元)',
field: 'amount', field: 'amount',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 100, minWidth: 90,
flex: 2, flex: 2,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost), editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost),
@ -383,37 +445,6 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }), valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableMoney valueFormatter: formatEditableMoney
}, },
{
headerName: '咨询分类系数',
field: 'consultCategoryFactor',
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatConsultCategoryFactor
},
{
headerName: '专业系数',
field: 'majorFactor',
width: 80,
minWidth: 70,
maxWidth: 90,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatMajorFactor
},
{ {
headerName: '基准预算(元)', headerName: '基准预算(元)',
marryChildren: true, marryChildren: true,
@ -431,6 +462,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
params.node?.rowPinned params.node?.rowPinned
? params.data?.benchmarkBudgetBasic ?? null ? params.data?.benchmarkBudgetBasic ?? null
: getBenchmarkBudgetSplitByAmount(params.data)?.basic ?? null, : getBenchmarkBudgetSplitByAmount(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
valueFormatter: formatReadonlyMoney valueFormatter: formatReadonlyMoney
}, },
{ {
@ -446,6 +478,22 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
params.node?.rowPinned params.node?.rowPinned
? params.data?.benchmarkBudgetOptional ?? null ? params.data?.benchmarkBudgetOptional ?? null
: getBenchmarkBudgetSplitByAmount(params.data)?.optional ?? null, : getBenchmarkBudgetSplitByAmount(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
valueFormatter: formatReadonlyMoney
},
{
headerName: '小计',
field: 'benchmarkBudget',
colId: 'benchmarkBudgetTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudget ?? null
: getBenchmarkBudgetSplitByAmount(params.data)?.total ?? null,
valueFormatter: formatReadonlyMoney valueFormatter: formatReadonlyMoney
} }
] ]
@ -455,41 +503,71 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
marryChildren: true, marryChildren: true,
children: [ children: [
{ {
headerName: '基本工作', headerName: '咨询分类系数',
field: 'budgetFeeBasic', field: 'consultCategoryFactor',
colId: 'budgetFeeBasic', colId: 'consultCategoryFactor',
headerClass: 'ag-right-aligned-header', minWidth: 80,
minWidth: 120,
flex: 1, flex: 1,
cellClass: 'ag-right-aligned-cell', editable: params => !params.node?.group && !params.node?.rowPinned,
aggFunc: decimalAggSum, cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
valueGetter: params => cellClassRules: {
params.node?.rowPinned 'editable-cell-empty': params =>
? params.data?.budgetFeeBasic ?? null !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
: getBudgetFeeSplit(params.data)?.basic ?? null, },
valueFormatter: formatReadonlyMoney valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatConsultCategoryFactor
}, },
{ {
headerName: '可选工作', headerName: '专业系数',
field: 'budgetFeeOptional', field: 'majorFactor',
colId: 'budgetFeeOptional', colId: 'majorFactor',
headerClass: 'ag-right-aligned-header', minWidth: 80,
minWidth: 120,
flex: 1, flex: 1,
cellClass: 'ag-right-aligned-cell', editable: params => !params.node?.group && !params.node?.rowPinned,
aggFunc: decimalAggSum, cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
valueGetter: params => cellClassRules: {
params.node?.rowPinned 'editable-cell-empty': params =>
? params.data?.budgetFeeOptional ?? null !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
: getBudgetFeeSplit(params.data)?.optional ?? null, },
valueFormatter: formatReadonlyMoney valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatMajorFactor
},
{
headerName: '工作环节系数(编审系数)',
field: 'workStageFactor',
colId: 'workStageFactor',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableNumber
},
{
headerName: '工作占比(%)',
field: 'workRatio',
colId: 'workRatio',
minWidth: 80,
flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: {
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableNumber
}, },
{ {
headerName: '合计', headerName: '合计',
field: 'budgetFee', field: 'budgetFee',
colId: 'budgetFeeTotal', colId: 'budgetFeeTotal',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 130, minWidth: 120,
flex: 1, flex: 1,
cellClass: 'ag-right-aligned-cell', cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum, aggFunc: decimalAggSum,
@ -501,7 +579,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{ {
headerName: '说明', headerName: '说明',
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 100,
flex: 1.2, flex: 1.2,
cellEditor: 'agLargeTextCellEditor', cellEditor: 'agLargeTextCellEditor',
wrapText: true, wrapText: true,
@ -570,10 +648,14 @@ const pinnedTopRowData = computed(() => [
benchmarkBudget: totalBenchmarkBudget.value, benchmarkBudget: totalBenchmarkBudget.value,
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value, benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
benchmarkBudgetOptional: totalBenchmarkBudgetOptional.value, benchmarkBudgetOptional: totalBenchmarkBudgetOptional.value,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '', basicFormula: '',
optionalFormula: '', optionalFormula: '',
consultCategoryFactor: null, consultCategoryFactor: null,
majorFactor: null, majorFactor: null,
workStageFactor: null,
workRatio: null,
budgetFee: totalBudgetFee.value, budgetFee: totalBudgetFee.value,
budgetFeeBasic: totalBudgetFeeBasic.value, budgetFeeBasic: totalBudgetFeeBasic.value,
budgetFeeOptional: totalBudgetFeeOptional.value, budgetFeeOptional: totalBudgetFeeOptional.value,
@ -582,30 +664,35 @@ const pinnedTopRowData = computed(() => [
} }
]) ])
const buildPersistDetailRows = () => const syncComputedValuesToDetailRows = () => {
detailRows.value.map(row => { for (const row of detailRows.value) {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByAmount(row) const benchmarkBudgetSplit = getBenchmarkBudgetSplitByAmount(row)
const budgetFeeSplit = benchmarkBudgetSplit const budgetFeeSplit = benchmarkBudgetSplit
? getScaleBudgetFeeSplit({ ? getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic, benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional, benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row.majorFactor, majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
}) })
: null : null
return { row.benchmarkBudget = benchmarkBudgetSplit?.total ?? null
...row, row.benchmarkBudgetBasic = benchmarkBudgetSplit?.basic ?? null
benchmarkBudget: benchmarkBudgetSplit?.total ?? null, row.benchmarkBudgetOptional = benchmarkBudgetSplit?.optional ?? null
benchmarkBudgetBasic: benchmarkBudgetSplit?.basic ?? null, row.basicFormula = benchmarkBudgetSplit?.basicFormula ?? ''
benchmarkBudgetOptional: benchmarkBudgetSplit?.optional ?? null, row.optionalFormula = benchmarkBudgetSplit?.optionalFormula ?? ''
basicFormula: benchmarkBudgetSplit?.basicFormula ?? '', row.budgetFee = budgetFeeSplit?.total ?? null
optionalFormula: benchmarkBudgetSplit?.optionalFormula ?? '', row.budgetFeeBasic = budgetFeeSplit?.basic ?? null
budgetFee: budgetFeeSplit?.total ?? null, row.budgetFeeOptional = budgetFeeSplit?.optional ?? null
budgetFeeBasic: budgetFeeSplit?.basic ?? null,
budgetFeeOptional: budgetFeeSplit?.optional ?? null
} }
}) }
const buildPersistDetailRows = () => {
syncComputedValuesToDetailRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return if (shouldSkipPersist()) return
@ -637,30 +724,35 @@ const loadFromIndexedDB = async () => {
await ensureFactorDefaultsLoaded() await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) { const applyContractDefaultRows = async () => {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
? mergeWithDictRows(htData.detailRows, { includeAmount: false, includeFactorValues: false }) detailRows.value = hasContractRows
: buildDefaultRows() ? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
syncComputedValuesToDetailRows()
}
if (shouldForceDefaultLoad()) {
await applyContractDefaultRows()
return return
} }
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) detailRows.value = mergeWithDictRows(data.detailRows)
syncComputedValuesToDetailRows()
return return
} }
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) await applyContractDefaultRows()
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows, { includeAmount: false, includeFactorValues: false })
return
}
detailRows.value = buildDefaultRows()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows()
syncComputedValuesToDetailRows()
} }
} }
@ -687,6 +779,15 @@ const importContractData = async () => {
} }
} }
const clearAllData = async () => {
try {
detailRows.value = buildDefaultRows()
await saveToIndexedDB()
} catch (error) {
console.error('clearAllData failed:', error)
}
}
watch( watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId), () => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => { (nextVersion, prevVersion) => {
@ -702,10 +803,11 @@ let persistTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => { const handleCellValueChanged = () => {
syncComputedValuesToDetailRows()
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void saveToIndexedDB()
}, 1000) }, 300)
} }
onMounted(async () => { onMounted(async () => {
@ -748,6 +850,29 @@ const processCellFromClipboard = (params: any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 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 justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3> <h3 class="text-sm font-semibold text-foreground">投资规模明细</h3>
<div class="flex items-center gap-2">
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">清空</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空当前投资规模明细是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="clearAllData">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</Button> <Button type="button" variant="outline" size="sm">使用默认数据</Button>
@ -771,6 +896,7 @@ const processCellFromClipboard = (params: any) => {
</AlertDialogPortal> </AlertDialogPortal>
</AlertDialogRoot> </AlertDialogRoot>
</div> </div>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1"> <div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
@ -784,5 +910,3 @@ const processCellFromClipboard = (params: any) => {
</div> </div>
</div> </div>
</template> </template>

View File

@ -56,10 +56,14 @@ interface DetailRow {
benchmarkBudget: number | null benchmarkBudget: number | null
benchmarkBudgetBasic: number | null benchmarkBudgetBasic: number | null
benchmarkBudgetOptional: number | null benchmarkBudgetOptional: number | null
benchmarkBudgetBasicChecked: boolean
benchmarkBudgetOptionalChecked: boolean
basicFormula: string basicFormula: string
optionalFormula: string optionalFormula: string
consultCategoryFactor: number | null consultCategoryFactor: number | null
majorFactor: number | null majorFactor: number | null
workStageFactor: number | null
workRatio: number | null
budgetFee: number | null budgetFee: number | null
budgetFeeBasic: number | null budgetFeeBasic: number | null
budgetFeeOptional: number | null budgetFeeOptional: number | null
@ -81,6 +85,8 @@ const props = defineProps<{
}>() }>()
const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`) const DB_KEY = computed(() => `ydGMF-${props.contractId}-${props.serviceId}`)
const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`) const HT_DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const HT_MAJOR_FACTOR_KEY = computed(() => `ht-major-factor-v1-${props.contractId}`)
const BASE_INFO_KEY = 'xm-base-info-v1' const BASE_INFO_KEY = 'xm-base-info-v1'
const activeIndustryCode = ref('') const activeIndustryCode = ref('')
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
@ -99,8 +105,8 @@ const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.
const loadFactorDefaults = async () => { const loadFactorDefaults = async () => {
const [consultMap, majorMap] = await Promise.all([ const [consultMap, majorMap] = await Promise.all([
loadConsultCategoryFactorMap(), loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value),
loadMajorFactorMap() loadMajorFactorMap(HT_MAJOR_FACTOR_KEY.value)
]) ])
consultCategoryFactorMap.value = consultMap consultCategoryFactorMap.value = consultMap
majorFactorMap.value = majorMap majorFactorMap.value = majorMap
@ -218,10 +224,14 @@ const buildDefaultRows = (): DetailRow[] => {
benchmarkBudget: null, benchmarkBudget: null,
benchmarkBudgetBasic: null, benchmarkBudgetBasic: null,
benchmarkBudgetOptional: null, benchmarkBudgetOptional: null,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '', basicFormula: '',
optionalFormula: '', optionalFormula: '',
consultCategoryFactor: null, consultCategoryFactor: null,
majorFactor: null, majorFactor: null,
workStageFactor: 1,
workRatio: 100,
budgetFee: null, budgetFee: null,
budgetFeeBasic: null, budgetFeeBasic: null,
budgetFeeOptional: null, budgetFeeOptional: null,
@ -242,10 +252,14 @@ type SourceRow = Pick<DetailRow, 'id'> &
| 'benchmarkBudget' | 'benchmarkBudget'
| 'benchmarkBudgetBasic' | 'benchmarkBudgetBasic'
| 'benchmarkBudgetOptional' | 'benchmarkBudgetOptional'
| 'benchmarkBudgetBasicChecked'
| 'benchmarkBudgetOptionalChecked'
| 'basicFormula' | 'basicFormula'
| 'optionalFormula' | 'optionalFormula'
| 'consultCategoryFactor' | 'consultCategoryFactor'
| 'majorFactor' | 'majorFactor'
| 'workStageFactor'
| 'workRatio'
| 'budgetFee' | 'budgetFee'
| 'budgetFeeBasic' | 'budgetFeeBasic'
| 'budgetFeeOptional' | 'budgetFeeOptional'
@ -281,6 +295,10 @@ const mergeWithDictRows = (
benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null, benchmarkBudget: typeof fromDb.benchmarkBudget === 'number' ? fromDb.benchmarkBudget : null,
benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null, benchmarkBudgetBasic: typeof fromDb.benchmarkBudgetBasic === 'number' ? fromDb.benchmarkBudgetBasic : null,
benchmarkBudgetOptional: typeof fromDb.benchmarkBudgetOptional === 'number' ? fromDb.benchmarkBudgetOptional : 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 : '', basicFormula: typeof fromDb.basicFormula === 'string' ? fromDb.basicFormula : '',
optionalFormula: typeof fromDb.optionalFormula === 'string' ? fromDb.optionalFormula : '', optionalFormula: typeof fromDb.optionalFormula === 'string' ? fromDb.optionalFormula : '',
consultCategoryFactor: consultCategoryFactor:
@ -299,6 +317,8 @@ const mergeWithDictRows = (
: hasMajorFactor : hasMajorFactor
? null ? null
: getDefaultMajorFactorById(row.id), : getDefaultMajorFactorById(row.id),
workStageFactor: typeof fromDb.workStageFactor === 'number' ? fromDb.workStageFactor : row.workStageFactor,
workRatio: typeof fromDb.workRatio === 'number' ? fromDb.workRatio : row.workRatio,
budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null, budgetFee: typeof fromDb.budgetFee === 'number' ? fromDb.budgetFee : null,
budgetFeeBasic: typeof fromDb.budgetFeeBasic === 'number' ? fromDb.budgetFeeBasic : null, budgetFeeBasic: typeof fromDb.budgetFeeBasic === 'number' ? fromDb.budgetFeeBasic : null,
budgetFeeOptional: typeof fromDb.budgetFeeOptional === 'number' ? fromDb.budgetFeeOptional : null, budgetFeeOptional: typeof fromDb.budgetFeeOptional === 'number' ? fromDb.budgetFeeOptional : null,
@ -328,10 +348,45 @@ const formatReadonlyMoney = (params: any) => {
return formatThousands(roundTo(params.value, 2)) return formatThousands(roundTo(params.value, 2))
} }
type BudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
const createBudgetCellRendererWithCheck = (checkField: BudgetCheckField) => (params: any) => {
const valueText = formatReadonlyMoney(params)
const hasValue = params.value != null && params.value !== ''
if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
return valueText
}
const wrapper = document.createElement('div')
wrapper.style.display = 'flex'
wrapper.style.alignItems = 'center'
wrapper.style.justifyContent = 'flex-end'
wrapper.style.gap = '6px'
wrapper.style.width = '100%'
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.className = 'cursor-pointer'
checkbox.checked = params.data[checkField] !== false
checkbox.addEventListener('click', event => event.stopPropagation())
checkbox.addEventListener('change', () => {
params.data[checkField] = checkbox.checked
handleCellValueChanged()
})
const valueSpan = document.createElement('span')
valueSpan.textContent = valueText
wrapper.append(checkbox, valueSpan)
return wrapper
}
const getBenchmarkBudgetSplitByLandArea = (row?: Pick<DetailRow, 'landArea'>) => const getBenchmarkBudgetSplitByLandArea = (row?: Pick<DetailRow, 'landArea'>) =>
getBenchmarkBudgetSplitByScale(row?.landArea, 'area') getBenchmarkBudgetSplitByScale(row?.landArea, 'area')
const getBudgetFee = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor'>) => { const getBudgetFee = (
row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
) => {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row) const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null if (!benchmarkBudgetSplit) return null
@ -339,19 +394,25 @@ const getBudgetFee = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consul
benchmarkBudgetBasic: benchmarkBudgetSplit.basic, benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional, benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor, majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
}) })
return splitBudgetFee ? splitBudgetFee.total : null return splitBudgetFee ? splitBudgetFee.total : null
} }
const getBudgetFeeSplit = (row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor'>) => { const getBudgetFeeSplit = (
row?: Pick<DetailRow, 'landArea' | 'majorFactor' | 'consultCategoryFactor' | 'workStageFactor' | 'workRatio'>
) => {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row) const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row)
if (!benchmarkBudgetSplit) return null if (!benchmarkBudgetSplit) return null
return getScaleBudgetFeeSplit({ return getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic, benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional, benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row?.majorFactor, majorFactor: row?.majorFactor,
consultCategoryFactor: row?.consultCategoryFactor consultCategoryFactor: row?.consultCategoryFactor,
workStageFactor: row?.workStageFactor,
workRatio: row?.workRatio
}) })
} }
@ -371,8 +432,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
headerName: '用地面积(亩)', headerName: '用地面积(亩)',
field: 'landArea', field: 'landArea',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 170, minWidth: 90,
flex: 1,
flex: 2,
editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea), editable: params => !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea),
cellClass: params => cellClass: params =>
@ -389,13 +451,69 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
}, },
valueFormatter: formatEditableFlexibleNumber valueFormatter: formatEditableFlexibleNumber
}, },
{
headerName: '基准预算(元)',
marryChildren: true,
children: [
{
headerName: '基本工作',
field: 'benchmarkBudgetBasic',
colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudgetBasic ?? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.basic ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
valueFormatter: formatReadonlyMoney
},
{
headerName: '可选工作',
field: 'benchmarkBudgetOptional',
colId: 'benchmarkBudgetOptional',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudgetOptional ?? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.optional ?? null,
cellRenderer: createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
valueFormatter: formatReadonlyMoney
},
{
headerName: '小计',
field: 'benchmarkBudget',
colId: 'benchmarkBudgetTotal',
headerClass: 'ag-right-aligned-header',
minWidth: 100,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.benchmarkBudget ?? null
: getBenchmarkBudgetSplitByLandArea(params.data)?.total ?? null,
valueFormatter: formatReadonlyMoney
}
]
},
{
headerName: '预算费用',
marryChildren: true,
children: [
{ {
headerName: '咨询分类系数', headerName: '咨询分类系数',
field: 'consultCategoryFactor', field: 'consultCategoryFactor',
width: 80, colId: 'consultCategoryFactor',
minWidth: 70, minWidth: 80,
maxWidth: 90, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: { cellClassRules: {
@ -408,9 +526,9 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{ {
headerName: '专业系数', headerName: '专业系数',
field: 'majorFactor', field: 'majorFactor',
width: 80, colId: 'majorFactor',
minWidth: 70, minWidth: 80,
maxWidth: 90, flex: 1,
editable: params => !params.node?.group && !params.node?.rowPinned, editable: params => !params.node?.group && !params.node?.rowPinned,
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''), cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
cellClassRules: { cellClassRules: {
@ -421,81 +539,41 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
valueFormatter: formatMajorFactor valueFormatter: formatMajorFactor
}, },
{ {
headerName: '基准预算(元)', headerName: '工作环节系数(编审系数)',
marryChildren: true, field: 'workStageFactor',
children: [ colId: 'workStageFactor',
{ minWidth: 80,
headerName: '基本工作',
field: 'benchmarkBudgetBasic',
colId: 'benchmarkBudgetBasic',
headerClass: 'ag-right-aligned-header',
minWidth: 140,
flex: 1, flex: 1,
cellClass: 'ag-right-aligned-cell', editable: params => !params.node?.group && !params.node?.rowPinned,
aggFunc: decimalAggSum, cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
valueGetter: params => cellClassRules: {
params.node?.rowPinned 'editable-cell-empty': params =>
? params.data?.benchmarkBudgetBasic ?? null !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
: getBenchmarkBudgetSplitByLandArea(params.data)?.basic ?? null, },
valueFormatter: formatReadonlyMoney valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableNumber
}, },
{ {
headerName: '可选工作', headerName: '工作占比(%)',
field: 'benchmarkBudgetOptional', field: 'workRatio',
colId: 'benchmarkBudgetOptional', colId: 'workRatio',
headerClass: 'ag-right-aligned-header', minWidth: 80,
minWidth: 140,
flex: 1, flex: 1,
cellClass: 'ag-right-aligned-cell', editable: params => !params.node?.group && !params.node?.rowPinned,
aggFunc: decimalAggSum, cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
valueGetter: params => cellClassRules: {
params.node?.rowPinned 'editable-cell-empty': params =>
? params.data?.benchmarkBudgetOptional ?? null !params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
: getBenchmarkBudgetSplitByLandArea(params.data)?.optional ?? null,
valueFormatter: formatReadonlyMoney
}
]
}, },
{ valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
headerName: '预算费用', valueFormatter: formatEditableNumber
marryChildren: true,
children: [
{
headerName: '基本工作',
field: 'budgetFeeBasic',
colId: 'budgetFeeBasic',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.budgetFeeBasic ?? null
: getBudgetFeeSplit(params.data)?.basic ?? null,
valueFormatter: formatReadonlyMoney
},
{
headerName: '可选工作',
field: 'budgetFeeOptional',
colId: 'budgetFeeOptional',
headerClass: 'ag-right-aligned-header',
minWidth: 130,
flex: 1,
cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum,
valueGetter: params =>
params.node?.rowPinned
? params.data?.budgetFeeOptional ?? null
: getBudgetFeeSplit(params.data)?.optional ?? null,
valueFormatter: formatReadonlyMoney
}, },
{ {
headerName: '合计', headerName: '合计',
field: 'budgetFee', field: 'budgetFee',
colId: 'budgetFeeTotal', colId: 'budgetFeeTotal',
headerClass: 'ag-right-aligned-header', headerClass: 'ag-right-aligned-header',
minWidth: 140, minWidth: 120,
flex: 1, flex: 1,
cellClass: 'ag-right-aligned-cell', cellClass: 'ag-right-aligned-cell',
aggFunc: decimalAggSum, aggFunc: decimalAggSum,
@ -507,7 +585,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
{ {
headerName: '说明', headerName: '说明',
field: 'remark', field: 'remark',
minWidth: 180, minWidth: 100,
flex: 1.2, flex: 1.2,
cellEditor: 'agLargeTextCellEditor', cellEditor: 'agLargeTextCellEditor',
wrapText: true, wrapText: true,
@ -529,7 +607,7 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
const autoGroupColumnDef: ColDef = { const autoGroupColumnDef: ColDef = {
headerName: '专业编码以及工程专业名称', headerName: '专业编码以及工程专业名称',
minWidth: 320, minWidth: 250,
pinned: 'left', pinned: 'left',
flex: 2, flex: 2,
@ -575,10 +653,14 @@ const pinnedTopRowData = computed(() => [
benchmarkBudget: totalBenchmarkBudget.value, benchmarkBudget: totalBenchmarkBudget.value,
benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value, benchmarkBudgetBasic: totalBenchmarkBudgetBasic.value,
benchmarkBudgetOptional: totalBenchmarkBudgetOptional.value, benchmarkBudgetOptional: totalBenchmarkBudgetOptional.value,
benchmarkBudgetBasicChecked: true,
benchmarkBudgetOptionalChecked: true,
basicFormula: '', basicFormula: '',
optionalFormula: '', optionalFormula: '',
consultCategoryFactor: null, consultCategoryFactor: null,
majorFactor: null, majorFactor: null,
workStageFactor: null,
workRatio: null,
budgetFee: totalBudgetFee.value, budgetFee: totalBudgetFee.value,
budgetFeeBasic: totalBudgetFeeBasic.value, budgetFeeBasic: totalBudgetFeeBasic.value,
budgetFeeOptional: totalBudgetFeeOptional.value, budgetFeeOptional: totalBudgetFeeOptional.value,
@ -587,30 +669,35 @@ const pinnedTopRowData = computed(() => [
} }
]) ])
const buildPersistDetailRows = () => const syncComputedValuesToDetailRows = () => {
detailRows.value.map(row => { for (const row of detailRows.value) {
const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row) const benchmarkBudgetSplit = getBenchmarkBudgetSplitByLandArea(row)
const budgetFeeSplit = benchmarkBudgetSplit const budgetFeeSplit = benchmarkBudgetSplit
? getScaleBudgetFeeSplit({ ? getScaleBudgetFeeSplit({
benchmarkBudgetBasic: benchmarkBudgetSplit.basic, benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
benchmarkBudgetOptional: benchmarkBudgetSplit.optional, benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
majorFactor: row.majorFactor, majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
}) })
: null : null
return { row.benchmarkBudget = benchmarkBudgetSplit?.total ?? null
...row, row.benchmarkBudgetBasic = benchmarkBudgetSplit?.basic ?? null
benchmarkBudget: benchmarkBudgetSplit?.total ?? null, row.benchmarkBudgetOptional = benchmarkBudgetSplit?.optional ?? null
benchmarkBudgetBasic: benchmarkBudgetSplit?.basic ?? null, row.basicFormula = benchmarkBudgetSplit?.basicFormula ?? ''
benchmarkBudgetOptional: benchmarkBudgetSplit?.optional ?? null, row.optionalFormula = benchmarkBudgetSplit?.optionalFormula ?? ''
basicFormula: benchmarkBudgetSplit?.basicFormula ?? '', row.budgetFee = budgetFeeSplit?.total ?? null
optionalFormula: benchmarkBudgetSplit?.optionalFormula ?? '', row.budgetFeeBasic = budgetFeeSplit?.basic ?? null
budgetFee: budgetFeeSplit?.total ?? null, row.budgetFeeOptional = budgetFeeSplit?.optional ?? null
budgetFeeBasic: budgetFeeSplit?.basic ?? null,
budgetFeeOptional: budgetFeeSplit?.optional ?? null
} }
}) }
const buildPersistDetailRows = () => {
syncComputedValuesToDetailRows()
return detailRows.value.map(row => ({ ...row }))
}
const saveToIndexedDB = async () => { const saveToIndexedDB = async () => {
if (shouldSkipPersist()) return if (shouldSkipPersist()) return
@ -641,30 +728,35 @@ const loadFromIndexedDB = async () => {
typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : '' typeof baseInfo?.projectIndustry === 'string' ? baseInfo.projectIndustry.trim() : ''
await ensureFactorDefaultsLoaded() await ensureFactorDefaultsLoaded()
if (shouldForceDefaultLoad()) { const applyContractDefaultRows = async () => {
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value)
detailRows.value = htData?.detailRows const hasContractRows = Array.isArray(htData?.detailRows) && htData.detailRows.length > 0
? mergeWithDictRows(htData.detailRows, { includeScaleValues: false, includeFactorValues: false }) detailRows.value = hasContractRows
: buildDefaultRows() ? mergeWithDictRows(htData!.detailRows, { includeFactorValues: true })
: buildDefaultRows().map(row => ({
...row,
consultCategoryFactor: getDefaultConsultCategoryFactor(),
majorFactor: getDefaultMajorFactorById(row.id)
}))
syncComputedValuesToDetailRows()
}
if (shouldForceDefaultLoad()) {
await applyContractDefaultRows()
return return
} }
const data = await localforage.getItem<XmInfoState>(DB_KEY.value) const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
if (data) { if (data) {
detailRows.value = mergeWithDictRows(data.detailRows) detailRows.value = mergeWithDictRows(data.detailRows)
syncComputedValuesToDetailRows()
return return
} }
const htData = await localforage.getItem<{ detailRows: SourceRow[] }>(HT_DB_KEY.value) await applyContractDefaultRows()
if (htData?.detailRows) {
detailRows.value = mergeWithDictRows(htData.detailRows, { includeScaleValues: false, includeFactorValues: false })
return
}
detailRows.value = buildDefaultRows()
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows() detailRows.value = buildDefaultRows()
syncComputedValuesToDetailRows()
} }
} }
@ -691,6 +783,15 @@ const importContractData = async () => {
} }
} }
const clearAllData = async () => {
try {
detailRows.value = buildDefaultRows()
await saveToIndexedDB()
} catch (error) {
console.error('clearAllData failed:', error)
}
}
watch( watch(
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId), () => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
(nextVersion, prevVersion) => { (nextVersion, prevVersion) => {
@ -706,10 +807,11 @@ let persistTimer: ReturnType<typeof setTimeout> | null = null
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
const handleCellValueChanged = () => { const handleCellValueChanged = () => {
syncComputedValuesToDetailRows()
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void saveToIndexedDB()
}, 1000) }, 300)
} }
onMounted(async () => { onMounted(async () => {
@ -752,6 +854,29 @@ const processCellFromClipboard = (params: any) => {
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 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 justify-between border-b px-4 py-3">
<h3 class="text-sm font-semibold text-foreground">用地规模明细</h3> <h3 class="text-sm font-semibold text-foreground">用地规模明细</h3>
<div class="flex items-center gap-2">
<AlertDialogRoot>
<AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">清空</Button>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空当前用地规模明细是否继续
</AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child>
<Button variant="outline">取消</Button>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button variant="destructive" @click="clearAllData">确认清空</Button>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<AlertDialogRoot> <AlertDialogRoot>
<AlertDialogTrigger as-child> <AlertDialogTrigger as-child>
<Button type="button" variant="outline" size="sm">使用默认数据</Button> <Button type="button" variant="outline" size="sm">使用默认数据</Button>
@ -775,6 +900,7 @@ const processCellFromClipboard = (params: any) => {
</AlertDialogPortal> </AlertDialogPortal>
</AlertDialogRoot> </AlertDialogRoot>
</div> </div>
</div>
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1"> <div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData" <AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"

View File

@ -43,6 +43,7 @@ const props = defineProps<{
serviceId: string | number serviceId: string | number
}>() }>()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`) 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:' const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:' const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
const pricingPaneReloadStore = usePricingPaneReloadStore() const pricingPaneReloadStore = usePricingPaneReloadStore()
@ -55,7 +56,7 @@ const getDefaultConsultCategoryFactor = () =>
const ensureFactorDefaultsLoaded = async () => { const ensureFactorDefaultsLoaded = async () => {
if (factorDefaultsLoaded) return if (factorDefaultsLoaded) return
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap() consultCategoryFactorMap.value = await loadConsultCategoryFactorMap(HT_CONSULT_FACTOR_KEY.value)
factorDefaultsLoaded = true factorDefaultsLoaded = true
} }
@ -481,7 +482,7 @@ const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void saveToIndexedDB()
}, 1000) }, 300)
} }
onMounted(async () => { onMounted(async () => {

View File

@ -9,7 +9,7 @@ import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal' import { addNumbers } from '@/lib/decimal'
import { parseNumberOrNull } from '@/lib/number' import { parseNumberOrNull } from '@/lib/number'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat' import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals' import { getPricingMethodTotalsForService, getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals'
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync' import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next' import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
import { import {
@ -311,19 +311,19 @@ const clearRowValues = async (row: DetailRow) => {
// ? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`) // ? tabStore.removeTab(`zxfw-edit-${props.contractId}-${row.id}`)
await nextTick() await nextTick()
await clearPricingPaneValues(row.id) await clearPricingPaneValues(row.id)
// const totals = await getPricingMethodTotalsForService({ const totals = await getPricingMethodTotalsForService({
// contractId: props.contractId, contractId: props.contractId,
// serviceId: row.id serviceId: row.id
// }) })
const clearedRows = detailRows.value.map(item => const clearedRows = detailRows.value.map(item =>
item.id !== row.id item.id !== row.id
? item ? item
: { : {
...item, ...item,
investScale: null, investScale: totals.investScale,
landScale: null, landScale: totals.landScale,
workload: null, workload: totals.workload,
hourly: null hourly: totals.hourly
} }
) )
const nextInvestScale = getMethodTotalFromRows(clearedRows, 'investScale') const nextInvestScale = getMethodTotalFromRows(clearedRows, 'investScale')
@ -384,7 +384,7 @@ const ActionCellRenderer = defineComponent({
]), ]),
h('button', { class: 'zxfw-action-btn', 'data-action': 'clear', type: 'button' }, [ h('button', { class: 'zxfw-action-btn', 'data-action': 'clear', type: 'button' }, [
h(Eraser, { size: 13, 'aria-hidden': 'true' }), h(Eraser, { size: 13, 'aria-hidden': 'true' }),
h('span', '清空') h('span', '恢复默认')
]), ]),
h('button', { class: 'zxfw-action-btn zxfw-action-btn--danger', 'data-action': 'delete', type: 'button' }, [ h('button', { class: 'zxfw-action-btn zxfw-action-btn--danger', 'data-action': 'delete', type: 'button' }, [
h(Trash2, { size: 13, 'aria-hidden': 'true' }), h(Trash2, { size: 13, 'aria-hidden': 'true' }),
@ -527,17 +527,47 @@ const detailGridOptions: GridOptions<DetailRow> = {
} }
} }
const fillPricingTotalsForSelectedRows = async () => { const applyFixedRowTotals = (rows: DetailRow[]) => {
const serviceRows = detailRows.value.filter(row => !isFixedRow(row)) const nextInvestScale = getMethodTotalFromRows(rows, 'investScale')
if (serviceRows.length === 0) return const nextLandScale = getMethodTotalFromRows(rows, 'landScale')
const nextWorkload = getMethodTotalFromRows(rows, 'workload')
const nextHourly = getMethodTotalFromRows(rows, 'hourly')
return rows.map(row =>
isFixedRow(row)
? {
...row,
investScale: nextInvestScale,
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: addNumbers(nextInvestScale, nextLandScale, nextWorkload, nextHourly)
}
: row
)
}
const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
const targetIds = Array.from(
new Set(
serviceIds.filter(id =>
detailRows.value.some(row => !isFixedRow(row) && String(row.id) === String(id))
)
)
)
if (targetIds.length === 0) {
detailRows.value = applyFixedRowTotals(detailRows.value)
return
}
const totalsByServiceId = await getPricingMethodTotalsForServices({ const totalsByServiceId = await getPricingMethodTotalsForServices({
contractId: props.contractId, contractId: props.contractId,
serviceIds: serviceRows.map(row => row.id) serviceIds: targetIds
}) })
detailRows.value = detailRows.value.map(row => { const targetSet = new Set(targetIds.map(id => String(id)))
if (isFixedRow(row)) return row const nextRows = detailRows.value.map(row => {
if (isFixedRow(row) || !targetSet.has(String(row.id))) return row
const totals = totalsByServiceId.get(String(row.id)) const totals = totalsByServiceId.get(String(row.id))
if (!totals) return row if (!totals) return row
return { return {
@ -548,6 +578,8 @@ const fillPricingTotalsForSelectedRows = async () => {
hourly: totals.hourly hourly: totals.hourly
} }
}) })
detailRows.value = applyFixedRowTotals(nextRows)
} }
const applySelection = (codes: string[]) => { const applySelection = (codes: string[]) => {
@ -601,7 +633,11 @@ const applySelection = (codes: string[]) => {
} }
const handleServiceSelectionChange = async (ids: string[]) => { const handleServiceSelectionChange = async (ids: string[]) => {
const prevIds = [...selectedIds.value]
applySelection(ids) applySelection(ids)
const nextSelectedSet = new Set(selectedIds.value)
const addedIds = selectedIds.value.filter(id => !prevIds.includes(id) && nextSelectedSet.has(id))
await fillPricingTotalsForServiceIds(addedIds)
await saveToIndexedDB() await saveToIndexedDB()
} }
@ -776,12 +812,7 @@ const loadFromIndexedDB = async () => {
hourly: typeof old.hourly === 'number' ? old.hourly : null hourly: typeof old.hourly === 'number' ? old.hourly : null
} }
}) })
detailRows.value = applyFixedRowTotals(detailRows.value)
try {
// await fillPricingTotalsForSelectedRows()
} catch (error) {
console.error('fillPricingTotalsForSelectedRows failed:', error)
}
} catch (error) { } catch (error) {
console.error('loadFromIndexedDB failed:', error) console.error('loadFromIndexedDB failed:', error)
selectedIds.value = [] selectedIds.value = []
@ -802,7 +833,7 @@ const handleCellValueChanged = () => {
if (gridPersistTimer) clearTimeout(gridPersistTimer) if (gridPersistTimer) clearTimeout(gridPersistTimer)
gridPersistTimer = setTimeout(() => { gridPersistTimer = setTimeout(() => {
void saveToIndexedDB() void saveToIndexedDB()
}, 1000) }, 300)
} }
onMounted(async () => { onMounted(async () => {
@ -849,16 +880,16 @@ onBeforeUnmount(() => {
<AlertDialogPortal> <AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" /> <AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl"> <AlertDialogContent class="fixed left-1/2 top-1/2 z-[70] w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空服务数据</AlertDialogTitle> <AlertDialogTitle class="text-base font-semibold">确认恢复默认数据</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground"> <AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空{{ pendingClearServiceName }}的计价数据是否继续 会使用合同卡片里面最新填写的规模信息以及系数自动计算默认数据覆盖{{ pendingClearServiceName }}当前数据是否继续
</AlertDialogDescription> </AlertDialogDescription>
<div class="mt-4 flex items-center justify-end gap-2"> <div class="mt-4 flex items-center justify-end gap-2">
<AlertDialogCancel as-child> <AlertDialogCancel as-child>
<Button variant="outline">取消</Button> <Button variant="outline">取消</Button>
</AlertDialogCancel> </AlertDialogCancel>
<AlertDialogAction as-child> <AlertDialogAction as-child>
<Button variant="destructive" @click="confirmClearRow">确认清空</Button> <Button variant="destructive" @click="confirmClearRow">确认恢复</Button>
</AlertDialogAction> </AlertDialogAction>
</div> </div>
</AlertDialogContent> </AlertDialogContent>

View File

@ -9,6 +9,7 @@ const borderConfig = {
export const myTheme = themeQuartz.withParams({ export const myTheme = themeQuartz.withParams({
wrapperBorder: false, wrapperBorder: false,
wrapperBorderRadius: 0,
headerBackgroundColor: '#f0f2f3', headerBackgroundColor: '#f0f2f3',
headerTextColor: '#374151', headerTextColor: '#374151',
headerFontSize: 15, headerFontSize: 15,
@ -25,6 +26,7 @@ export const gridOptions: GridOptions = {
tooltipShowMode: 'whenTruncated', tooltipShowMode: 'whenTruncated',
suppressAggFuncInHeader: true, suppressAggFuncInHeader: true,
singleClickEdit: true, singleClickEdit: true,
stopEditingWhenCellsLoseFocus: true,
suppressClickEdit: false, suppressClickEdit: false,
suppressContextMenu: false, suppressContextMenu: false,
groupDefaultExpanded: -1, groupDefaultExpanded: -1,

View File

@ -14,6 +14,14 @@ interface StoredDetailRowsState<T = any> {
detailRows?: T[] detailRows?: T[]
} }
interface StoredFactorState {
detailRows?: Array<{
id: string
standardFactor?: number | null
budgetValue?: number | null
}>
}
type MaybeNumber = number | null | undefined type MaybeNumber = number | null | undefined
interface ScaleRow { interface ScaleRow {
@ -22,6 +30,8 @@ interface ScaleRow {
landArea: number | null landArea: number | null
consultCategoryFactor: number | null consultCategoryFactor: number | null
majorFactor: number | null majorFactor: number | null
workStageFactor: number | null
workRatio: number | null
} }
interface WorkloadRow { interface WorkloadRow {
@ -92,25 +102,74 @@ const getDefaultMajorFactorById = (id: string) => {
return toFiniteNumberOrNull(major?.defCoe) return toFiniteNumberOrNull(major?.defCoe)
} }
const resolveFactorValue = (
row: { budgetValue?: number | null; standardFactor?: number | null } | undefined,
fallback: number | null
) => {
if (!row) return fallback
const budgetValue = toFiniteNumberOrNull(row.budgetValue)
if (budgetValue != null) return budgetValue
const standardFactor = toFiniteNumberOrNull(row.standardFactor)
if (standardFactor != null) return standardFactor
return fallback
}
const buildConsultCategoryFactorMap = (state: StoredFactorState | null) => {
const map = new Map<string, number | null>()
const serviceDict = getServiceDictById() as Record<string, ServiceLite | undefined>
for (const [id, item] of Object.entries(serviceDict)) {
map.set(String(id), toFiniteNumberOrNull(item?.defCoe))
}
for (const row of state?.detailRows || []) {
if (!row?.id) continue
const id = String(row.id)
map.set(id, resolveFactorValue(row, map.get(id) ?? null))
}
return map
}
const buildMajorFactorMap = (state: StoredFactorState | null) => {
const map = new Map<string, number | null>()
for (const [id, item] of majorById.entries()) {
map.set(String(id), toFiniteNumberOrNull(item?.defCoe))
}
for (const row of state?.detailRows || []) {
if (!row?.id) continue
const rowId = String(row.id)
const id = map.has(rowId) ? rowId : majorIdAliasMap.get(rowId) || rowId
map.set(id, resolveFactorValue(row, map.get(id) ?? null))
}
return map
}
const getMajorLeafIds = () => const getMajorLeafIds = () =>
getMajorDictEntries() getMajorDictEntries()
.filter(({ item }) => Boolean(item?.code && String(item.code).includes('-'))) .filter(({ item }) => Boolean(item?.code && String(item.code).includes('-')))
.map(({ id }) => id) .map(({ id }) => id)
const buildDefaultScaleRows = (serviceId: string | number): ScaleRow[] => { const buildDefaultScaleRows = (
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId) serviceId: string | number,
consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>
): ScaleRow[] => {
const defaultConsultCategoryFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
return getMajorLeafIds().map(id => ({ return getMajorLeafIds().map(id => ({
id, id,
amount: null, amount: null,
landArea: null, landArea: null,
consultCategoryFactor: defaultConsultCategoryFactor, consultCategoryFactor: defaultConsultCategoryFactor,
majorFactor: getDefaultMajorFactorById(id) majorFactor: majorFactorMap?.get(id) ?? getDefaultMajorFactorById(id),
workStageFactor: 1,
workRatio: 100
})) }))
} }
const mergeScaleRows = ( const mergeScaleRows = (
serviceId: string | number, serviceId: string | number,
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>
): ScaleRow[] => { ): ScaleRow[] => {
const dbValueMap = toRowMap(rowsFromDb) const dbValueMap = toRowMap(rowsFromDb)
for (const row of rowsFromDb || []) { for (const row of rowsFromDb || []) {
@ -121,13 +180,16 @@ const mergeScaleRows = (
} }
} }
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId) const defaultConsultCategoryFactor =
return buildDefaultScaleRows(serviceId).map(row => { consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap).map(row => {
const fromDb = dbValueMap.get(row.id) const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row if (!fromDb) return row
const hasConsultCategoryFactor = hasOwn(fromDb, 'consultCategoryFactor') const hasConsultCategoryFactor = hasOwn(fromDb, 'consultCategoryFactor')
const hasMajorFactor = hasOwn(fromDb, 'majorFactor') const hasMajorFactor = hasOwn(fromDb, 'majorFactor')
const hasWorkStageFactor = hasOwn(fromDb, 'workStageFactor')
const hasWorkRatio = hasOwn(fromDb, 'workRatio')
return { return {
...row, ...row,
@ -138,7 +200,13 @@ const mergeScaleRows = (
(hasConsultCategoryFactor ? null : defaultConsultCategoryFactor), (hasConsultCategoryFactor ? null : defaultConsultCategoryFactor),
majorFactor: majorFactor:
toFiniteNumberOrNull(fromDb.majorFactor) ?? toFiniteNumberOrNull(fromDb.majorFactor) ??
(hasMajorFactor ? null : getDefaultMajorFactorById(row.id)) (hasMajorFactor ? null : (majorFactorMap?.get(row.id) ?? getDefaultMajorFactorById(row.id))),
workStageFactor:
toFiniteNumberOrNull((fromDb as Partial<ScaleRow>).workStageFactor) ??
(hasWorkStageFactor ? null : row.workStageFactor),
workRatio:
toFiniteNumberOrNull((fromDb as Partial<ScaleRow>).workRatio) ??
(hasWorkRatio ? null : row.workRatio)
} }
}) })
} }
@ -153,7 +221,9 @@ const getInvestmentBudgetFee = (row: ScaleRow) => {
return getScaleBudgetFee({ return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(row.amount), benchmarkBudget: getBenchmarkBudgetByAmount(row.amount),
majorFactor: row.majorFactor, majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
}) })
} }
@ -161,7 +231,9 @@ const getLandBudgetFee = (row: ScaleRow) => {
return getScaleBudgetFee({ return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByLandArea(row.landArea), benchmarkBudget: getBenchmarkBudgetByLandArea(row.landArea),
majorFactor: row.majorFactor, majorFactor: row.majorFactor,
consultCategoryFactor: row.consultCategoryFactor consultCategoryFactor: row.consultCategoryFactor,
workStageFactor: row.workStageFactor,
workRatio: row.workRatio
}) })
} }
@ -170,8 +242,12 @@ const getTaskEntriesByServiceId = (serviceId: string | number) =>
.sort((a, b) => Number(a[0]) - Number(b[0])) .sort((a, b) => Number(a[0]) - Number(b[0]))
.filter(([, task]) => Number(task.serviceID) === Number(serviceId)) .filter(([, task]) => Number(task.serviceID) === Number(serviceId))
const buildDefaultWorkloadRows = (serviceId: string | number): WorkloadRow[] => { const buildDefaultWorkloadRows = (
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId) serviceId: string | number,
consultCategoryFactorMap?: Map<string, number | null>
): WorkloadRow[] => {
const defaultConsultCategoryFactor =
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
return getTaskEntriesByServiceId(serviceId).map(([taskId, task], order) => ({ return getTaskEntriesByServiceId(serviceId).map(([taskId, task], order) => ({
id: `task-${taskId}-${order}`, id: `task-${taskId}-${order}`,
conversion: toFiniteNumberOrNull(task.conversion), conversion: toFiniteNumberOrNull(task.conversion),
@ -184,11 +260,12 @@ const buildDefaultWorkloadRows = (serviceId: string | number): WorkloadRow[] =>
const mergeWorkloadRows = ( const mergeWorkloadRows = (
serviceId: string | number, serviceId: string | number,
rowsFromDb: Array<Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>> | undefined rowsFromDb: Array<Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>> | undefined,
consultCategoryFactorMap?: Map<string, number | null>
): WorkloadRow[] => { ): WorkloadRow[] => {
const dbValueMap = toRowMap(rowsFromDb) const dbValueMap = toRowMap(rowsFromDb)
return buildDefaultWorkloadRows(serviceId).map(row => { return buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap).map(row => {
const fromDb = dbValueMap.get(row.id) const fromDb = dbValueMap.get(row.id)
if (!fromDb) return row if (!fromDb) return row
@ -270,15 +347,27 @@ const calcHourlyServiceBudget = (row: HourlyRow) => {
const resolveScaleRows = ( const resolveScaleRows = (
serviceId: string, serviceId: string,
pricingData: StoredDetailRowsState | null, pricingData: StoredDetailRowsState | null,
htData: StoredDetailRowsState | null htData: StoredDetailRowsState | null,
consultCategoryFactorMap?: Map<string, number | null>,
majorFactorMap?: Map<string, number | null>
) => { ) => {
if (pricingData?.detailRows != null) { if (pricingData?.detailRows != null) {
return mergeScaleRows(serviceId, pricingData.detailRows as any) return mergeScaleRows(
serviceId,
pricingData.detailRows as any,
consultCategoryFactorMap,
majorFactorMap
)
} }
if (htData?.detailRows != null) { if (htData?.detailRows != null) {
return mergeScaleRows(serviceId, htData.detailRows as any) return mergeScaleRows(
serviceId,
htData.detailRows as any,
consultCategoryFactorMap,
majorFactorMap
)
} }
return buildDefaultScaleRows(serviceId) return buildDefaultScaleRows(serviceId, consultCategoryFactorMap, majorFactorMap)
} }
export const getPricingMethodTotalsForService = async (params: { export const getPricingMethodTotalsForService = async (params: {
@ -287,33 +376,52 @@ export const getPricingMethodTotalsForService = async (params: {
}): Promise<PricingMethodTotals> => { }): Promise<PricingMethodTotals> => {
const serviceId = String(params.serviceId) const serviceId = String(params.serviceId)
const htDbKey = `ht-info-v3-${params.contractId}` const htDbKey = `ht-info-v3-${params.contractId}`
const consultFactorDbKey = `ht-consult-category-factor-v1-${params.contractId}`
const majorFactorDbKey = `ht-major-factor-v1-${params.contractId}`
const investDbKey = `tzGMF-${params.contractId}-${serviceId}` const investDbKey = `tzGMF-${params.contractId}-${serviceId}`
const landDbKey = `ydGMF-${params.contractId}-${serviceId}` const landDbKey = `ydGMF-${params.contractId}-${serviceId}`
const workloadDbKey = `gzlF-${params.contractId}-${serviceId}` const workloadDbKey = `gzlF-${params.contractId}-${serviceId}`
const hourlyDbKey = `hourlyPricing-${params.contractId}-${serviceId}` const hourlyDbKey = `hourlyPricing-${params.contractId}-${serviceId}`
const [investData, landData, workloadData, hourlyData, htData] = await Promise.all([ const [investData, landData, workloadData, hourlyData, htData, consultFactorData, majorFactorData] = await Promise.all([
localforage.getItem<StoredDetailRowsState>(investDbKey), localforage.getItem<StoredDetailRowsState>(investDbKey),
localforage.getItem<StoredDetailRowsState>(landDbKey), localforage.getItem<StoredDetailRowsState>(landDbKey),
localforage.getItem<StoredDetailRowsState>(workloadDbKey), localforage.getItem<StoredDetailRowsState>(workloadDbKey),
localforage.getItem<StoredDetailRowsState>(hourlyDbKey), localforage.getItem<StoredDetailRowsState>(hourlyDbKey),
localforage.getItem<StoredDetailRowsState>(htDbKey) localforage.getItem<StoredDetailRowsState>(htDbKey),
localforage.getItem<StoredFactorState>(consultFactorDbKey),
localforage.getItem<StoredFactorState>(majorFactorDbKey)
]) ])
const consultCategoryFactorMap = buildConsultCategoryFactorMap(consultFactorData)
const majorFactorMap = buildMajorFactorMap(majorFactorData)
// 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。 // 优先使用对应计费页的数据;不存在时回退合同段规模信息,再回退默认字典行。
const investRows = resolveScaleRows(serviceId, investData, htData) const investRows = resolveScaleRows(
serviceId,
investData,
htData,
consultCategoryFactorMap,
majorFactorMap
)
const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row)) const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row))
const landRows = resolveScaleRows(serviceId, landData, htData) const landRows = resolveScaleRows(
serviceId,
landData,
htData,
consultCategoryFactorMap,
majorFactorMap
)
const landScale = sumByNumber(landRows, row => getLandBudgetFee(row)) const landScale = sumByNumber(landRows, row => getLandBudgetFee(row))
const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId) const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap)
const workload = const workload =
defaultWorkloadRows.length === 0 defaultWorkloadRows.length === 0
? null ? null
: sumByNumber( : sumByNumber(
workloadData?.detailRows != null workloadData?.detailRows != null
? mergeWorkloadRows(serviceId, workloadData.detailRows as any) ? mergeWorkloadRows(serviceId, workloadData.detailRows as any, consultCategoryFactorMap)
: defaultWorkloadRows, : defaultWorkloadRows,
row => calcWorkloadServiceFee(row) row => calcWorkloadServiceFee(row)
) )

View File

@ -43,28 +43,42 @@ export const getScaleBudgetFeeSplit = (params: {
benchmarkBudgetOptional: unknown benchmarkBudgetOptional: unknown
majorFactor: unknown majorFactor: unknown
consultCategoryFactor: unknown consultCategoryFactor: unknown
workStageFactor?: unknown
workRatio?: unknown
}): ScaleFeeSplitResult | null => { }): ScaleFeeSplitResult | null => {
const hasWorkStageFactor = Object.prototype.hasOwnProperty.call(params, 'workStageFactor')
const hasWorkRatio = Object.prototype.hasOwnProperty.call(params, 'workRatio')
const benchmarkBudgetBasic = toFiniteNumberOrNull(params.benchmarkBudgetBasic) const benchmarkBudgetBasic = toFiniteNumberOrNull(params.benchmarkBudgetBasic)
const benchmarkBudgetOptional = toFiniteNumberOrNull(params.benchmarkBudgetOptional) const benchmarkBudgetOptional = toFiniteNumberOrNull(params.benchmarkBudgetOptional)
const majorFactor = toFiniteNumberOrNull(params.majorFactor) const majorFactor = toFiniteNumberOrNull(params.majorFactor)
const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor) const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor)
const workStageFactor = hasWorkStageFactor ? toFiniteNumberOrNull(params.workStageFactor) : 1
const workRatio = hasWorkRatio ? toFiniteNumberOrNull(params.workRatio) : 100
if ( if (
benchmarkBudgetBasic == null || benchmarkBudgetBasic == null ||
benchmarkBudgetOptional == null || benchmarkBudgetOptional == null ||
majorFactor == null || majorFactor == null ||
consultCategoryFactor == null consultCategoryFactor == null ||
workStageFactor == null ||
workRatio == null
) { ) {
return null return null
} }
const basic = roundTo(toDecimal(benchmarkBudgetBasic).mul(majorFactor).mul(consultCategoryFactor), 2) const multiplier = toDecimal(consultCategoryFactor)
const optional = roundTo(toDecimal(benchmarkBudgetOptional).mul(majorFactor).mul(consultCategoryFactor), 2) .mul(majorFactor)
.mul(workStageFactor)
.mul(workRatio)
.div(100)
const roundedBenchmarkBudget = roundTo(addNumbers(benchmarkBudgetBasic, benchmarkBudgetOptional), 2)
const basic = roundTo(toDecimal(benchmarkBudgetBasic).mul(multiplier), 2)
const optional = roundTo(toDecimal(benchmarkBudgetOptional).mul(multiplier), 2)
return { return {
basic, basic,
optional, optional,
total: roundTo(addNumbers(basic, optional), 2), total: roundTo(toDecimal(roundedBenchmarkBudget).mul(multiplier), 2),
basicFormula: '', basicFormula: '',
optionalFormula: '' optionalFormula: ''
} }
@ -74,14 +88,32 @@ export const getScaleBudgetFee = (params: {
benchmarkBudget: unknown benchmarkBudget: unknown
majorFactor: unknown majorFactor: unknown
consultCategoryFactor: unknown consultCategoryFactor: unknown
workStageFactor?: unknown
workRatio?: unknown
}) => { }) => {
const hasWorkStageFactor = Object.prototype.hasOwnProperty.call(params, 'workStageFactor')
const hasWorkRatio = Object.prototype.hasOwnProperty.call(params, 'workRatio')
const benchmarkBudget = toFiniteNumberOrNull(params.benchmarkBudget) const benchmarkBudget = toFiniteNumberOrNull(params.benchmarkBudget)
const majorFactor = toFiniteNumberOrNull(params.majorFactor) const majorFactor = toFiniteNumberOrNull(params.majorFactor)
const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor) const consultCategoryFactor = toFiniteNumberOrNull(params.consultCategoryFactor)
const workStageFactor = hasWorkStageFactor ? toFiniteNumberOrNull(params.workStageFactor) : 1
const workRatio = hasWorkRatio ? toFiniteNumberOrNull(params.workRatio) : 100
if (benchmarkBudget == null || majorFactor == null || consultCategoryFactor == null) { if (
benchmarkBudget == null ||
majorFactor == null ||
consultCategoryFactor == null ||
workStageFactor == null ||
workRatio == null
) {
return null return null
} }
return roundTo(toDecimal(benchmarkBudget).mul(majorFactor).mul(consultCategoryFactor), 2) const roundedBenchmarkBudget = roundTo(benchmarkBudget, 2)
const multiplier = toDecimal(consultCategoryFactor)
.mul(majorFactor)
.mul(workStageFactor)
.mul(workRatio)
.div(100)
return roundTo(toDecimal(roundedBenchmarkBudget).mul(multiplier), 2)
} }

View File

@ -57,8 +57,8 @@ const loadFactorMap = async (
return map return map
} }
export const loadConsultCategoryFactorMap = async () => export const loadConsultCategoryFactorMap = async (storageKey = CONSULT_CATEGORY_FACTOR_KEY) =>
loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY, getServiceDictById() as FactorDict) loadFactorMap(storageKey, getServiceDictById() as FactorDict)
export const loadMajorFactorMap = async () => export const loadMajorFactorMap = async (storageKey = MAJOR_FACTOR_KEY) =>
loadFactorMap(MAJOR_FACTOR_KEY, getMajorDictById() as FactorDict, getMajorIdAliasMap()) loadFactorMap(storageKey, getMajorDictById() as FactorDict, getMajorIdAliasMap())

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/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/commonaggrid.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.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/contractdetailview.vue","./src/components/views/ht.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xm.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.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/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingscalefee.ts","./src/lib/utils.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/pricingpanereload.ts","./src/pinia/tab.ts","./src/app.vue","./src/components/common/methodunavailablenotice.vue","./src/components/common/xmfactorgrid.vue","./src/components/common/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/ht.vue","./src/components/views/htconsultcategoryfactor.vue","./src/components/views/htmajorfactor.vue","./src/components/views/servicecheckboxselector.vue","./src/components/views/xmconsultcategoryfactor.vue","./src/components/views/xmmajorfactor.vue","./src/components/views/zxfwview.vue","./src/components/views/htcard.vue","./src/components/views/htinfo.vue","./src/components/views/info.vue","./src/components/views/xmcard.vue","./src/components/views/xminfo.vue","./src/components/views/zxfw.vue","./src/components/views/pricingview/hourlypricingpane.vue","./src/components/views/pricingview/investmentscalepricingpane.vue","./src/components/views/pricingview/landscalepricingpane.vue","./src/components/views/pricingview/workloadpricingpane.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}

View File

@ -5,7 +5,7 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig({ export default defineConfig({
plugins: [vue(),tailwindcss()], plugins: [vue(), tailwindcss()],
// 路径别名(和 tsconfig 保持一致) // 路径别名(和 tsconfig 保持一致)
resolve: { resolve: {
alias: { alias: {
@ -20,7 +20,10 @@ export default defineConfig({
assetsDir: 'static', assetsDir: 'static',
// 3. 生成的静态资源文件名是否包含哈希(用于缓存控制) // 3. 生成的静态资源文件名是否包含哈希(用于缓存控制)
assetsInlineLimit: 4096, // 小于4kb的资源内联不生成文件 assetsInlineLimit: 4096, // 小于4kb的资源内联不生成文件
rollupOptions: { rolldownOptions: {
checks: {
pluginTimings: false
},
// 4. 自定义入口/出口(进阶,一般无需修改) // 4. 自定义入口/出口(进阶,一般无需修改)
output: { output: {
// 自定义 chunk 文件名(拆分公共代码) // 自定义 chunk 文件名(拆分公共代码)
@ -53,7 +56,7 @@ export default defineConfig({
} }
} }
}, },
chunkSizeWarningLimit: 800, chunkSizeWarningLimit: 1800,
// 5. 生产环境是否生成 sourcemap默认 false关闭可减小包体积 // 5. 生产环境是否生成 sourcemap默认 false关闭可减小包体积
sourcemap: false, sourcemap: false,
// 6. 清空输出目录(默认 true // 6. 清空输出目录(默认 true