完成大部分
This commit is contained in:
parent
e97707ac59
commit
13b03e016e
@ -25,10 +25,7 @@ interface ContractItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'ht-card-v1'
|
const STORAGE_KEY = 'ht-card-v1'
|
||||||
const formStore = localforage.createInstance({
|
|
||||||
name: 'jgjs-pricing-db',
|
|
||||||
storeName: 'form_state'
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
|
|
||||||
@ -221,7 +218,6 @@ const removeRelatedTabsByContractId = (contractId: string) => {
|
|||||||
const cleanupContractRelatedData = async (contractId: string) => {
|
const cleanupContractRelatedData = async (contractId: string) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
removeForageKeysByContractId(localforage, contractId),
|
removeForageKeysByContractId(localforage, contractId),
|
||||||
removeForageKeysByContractId(formStore as any, contractId)
|
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -14,9 +14,17 @@ import TypeLine from '@/layout/typeLine.vue'
|
|||||||
|
|
||||||
const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
|
const xmView = markRaw(defineAsyncComponent(() => import('@/components/views/xmInfo.vue')))
|
||||||
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
const htView = markRaw(defineAsyncComponent(() => import('@/components/views/Ht.vue')))
|
||||||
|
const consultCategoryFactorView = markRaw(
|
||||||
|
defineAsyncComponent(() => import('@/components/views/XmConsultCategoryFactor.vue'))
|
||||||
|
)
|
||||||
|
const majorFactorView = markRaw(
|
||||||
|
defineAsyncComponent(() => import('@/components/views/XmMajorFactor.vue'))
|
||||||
|
)
|
||||||
|
|
||||||
const xmCategories = [
|
const xmCategories = [
|
||||||
{ key: 'info', label: '基础信息', component: xmView },
|
{ key: 'info', label: '基础信息', component: xmView },
|
||||||
{ key: 'contract', label: '合同段管理', component: htView }
|
{ key: 'contract', label: '合同段管理', component: htView },
|
||||||
|
{ key: 'consult-category-factor', label: '咨询分类系数', component: consultCategoryFactorView },
|
||||||
|
{ key: 'major-factor', label: '工程专业系数', component: majorFactorView }
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
14
src/components/views/XmConsultCategoryFactor.vue
Normal file
14
src/components/views/XmConsultCategoryFactor.vue
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { serviceList } from '@/sql'
|
||||||
|
import XmFactorGrid from '@/components/views/XmFactorGrid.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<XmFactorGrid
|
||||||
|
title="咨询分类系数明细"
|
||||||
|
storage-key="xm-consult-category-factor-v1"
|
||||||
|
:dict="serviceList"
|
||||||
|
:disable-budget-edit-when-standard-null="true"
|
||||||
|
:exclude-notshow-by-zxflxs="true"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
331
src/components/views/XmFactorGrid.vue
Normal file
331
src/components/views/XmFactorGrid.vue
Normal file
@ -0,0 +1,331 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
|
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
||||||
|
import localforage from 'localforage'
|
||||||
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
|
|
||||||
|
interface DictItem {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
defCoe: number | null
|
||||||
|
desc?: string | null
|
||||||
|
notshowByzxflxs?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FactorRow {
|
||||||
|
id: string
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
standardFactor: number | null
|
||||||
|
budgetValue: number | null
|
||||||
|
remark: string
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GridState {
|
||||||
|
detailRows: FactorRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type DictSource = Record<string, DictItem>
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
title: string
|
||||||
|
storageKey: string
|
||||||
|
dict: DictSource
|
||||||
|
disableBudgetEditWhenStandardNull?: boolean
|
||||||
|
excludeNotshowByZxflxs?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const detailRows = ref<FactorRow[]>([])
|
||||||
|
const gridApi = ref<GridApi<FactorRow> | null>(null)
|
||||||
|
|
||||||
|
const parseNumberOrNull = (value: unknown) => {
|
||||||
|
if (value === '' || value == null) return null
|
||||||
|
const v = Number(value)
|
||||||
|
return Number.isFinite(v) ? v : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatReadonlyFactor = (value: unknown) => {
|
||||||
|
if (value == null || value === '') return ''
|
||||||
|
return Number(value).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatEditableFactor = (params: any) => {
|
||||||
|
if (params.value == null || params.value === '') return '点击输入'
|
||||||
|
return Number(params.value).toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortedDictEntries = () =>
|
||||||
|
Object.entries(props.dict)
|
||||||
|
.filter((entry): entry is [string, DictItem] => {
|
||||||
|
const item = entry[1]
|
||||||
|
if (!item?.code || !item?.name) return false
|
||||||
|
if (props.excludeNotshowByZxflxs && item.notshowByzxflxs === true) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aNum = Number(a[0])
|
||||||
|
const bNum = Number(b[0])
|
||||||
|
if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum
|
||||||
|
return String(a[0]).localeCompare(String(b[0]))
|
||||||
|
})
|
||||||
|
|
||||||
|
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
|
||||||
|
const parts = code.split('-').filter(Boolean)
|
||||||
|
if (!parts.length) return [selfId]
|
||||||
|
|
||||||
|
const path: string[] = []
|
||||||
|
let currentCode = parts[0]
|
||||||
|
const firstId = codeIdMap.get(currentCode)
|
||||||
|
if (firstId) path.push(firstId)
|
||||||
|
|
||||||
|
for (let i = 1; i < parts.length; i += 1) {
|
||||||
|
currentCode = `${currentCode}-${parts[i]}`
|
||||||
|
const id = codeIdMap.get(currentCode)
|
||||||
|
if (id) path.push(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDefaultRows = (): FactorRow[] => {
|
||||||
|
const entries = sortedDictEntries()
|
||||||
|
const codeIdMap = new Map<string, string>()
|
||||||
|
for (const [id, item] of entries) {
|
||||||
|
codeIdMap.set(item.code, id)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entries.map(([id, item]) => {
|
||||||
|
const standardFactor = typeof item.defCoe === 'number' && Number.isFinite(item.defCoe) ? item.defCoe : null
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
standardFactor,
|
||||||
|
budgetValue: null,
|
||||||
|
remark: '',
|
||||||
|
path: buildCodePath(item.code, id, codeIdMap)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type SourceRow = Pick<FactorRow, 'id'> & Partial<Pick<FactorRow, 'budgetValue' | 'remark'>>
|
||||||
|
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
|
||||||
|
const dbValueMap = new Map<string, SourceRow>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(row.id, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
const hasBudgetValue = Object.prototype.hasOwnProperty.call(fromDb, 'budgetValue')
|
||||||
|
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
budgetValue:
|
||||||
|
typeof fromDb.budgetValue === 'number'
|
||||||
|
? fromDb.budgetValue
|
||||||
|
: hasBudgetValue
|
||||||
|
? null
|
||||||
|
: row.budgetValue,
|
||||||
|
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const columnDefs: ColDef<FactorRow>[] = [
|
||||||
|
{
|
||||||
|
headerName: '标准系数',
|
||||||
|
field: 'standardFactor',
|
||||||
|
minWidth: 86,
|
||||||
|
maxWidth: 100,
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
|
flex: 0.9,
|
||||||
|
valueFormatter: params => formatReadonlyFactor(params.value)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: '预算取值',
|
||||||
|
field: 'budgetValue',
|
||||||
|
minWidth: 86,
|
||||||
|
maxWidth: 100,
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
|
cellClass: params => {
|
||||||
|
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||||
|
return disabled ? 'ag-right-aligned-cell' : 'ag-right-aligned-cell editable-cell-line'
|
||||||
|
},
|
||||||
|
flex: 0.9,
|
||||||
|
cellClassRules: {
|
||||||
|
'editable-cell-empty': params => {
|
||||||
|
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||||
|
return !disabled && (params.value == null || params.value === '')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editable: params => {
|
||||||
|
if (!props.disableBudgetEditWhenStandardNull) return true
|
||||||
|
return params.data?.standardFactor != null
|
||||||
|
},
|
||||||
|
valueParser: params => parseNumberOrNull(params.newValue),
|
||||||
|
valueFormatter: params => {
|
||||||
|
const disabled = props.disableBudgetEditWhenStandardNull && params.data?.standardFactor == null
|
||||||
|
if (disabled && (params.value == null || params.value === '')) return ''
|
||||||
|
return formatEditableFactor(params)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headerName: '说明',
|
||||||
|
field: 'remark',
|
||||||
|
minWidth: 170,
|
||||||
|
flex: 2.4,
|
||||||
|
cellEditor: 'agLargeTextCellEditor',
|
||||||
|
wrapText: true,
|
||||||
|
autoHeight: true,
|
||||||
|
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||||
|
editable: true,
|
||||||
|
valueFormatter: params => params.value || '点击输入',
|
||||||
|
cellClass: 'editable-cell-line remark-wrap-cell',
|
||||||
|
cellClassRules: {
|
||||||
|
'editable-cell-empty': params => params.value == null || params.value === ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const autoGroupColumnDef: ColDef<FactorRow> = {
|
||||||
|
headerName: '专业编码以及工程专业名称',
|
||||||
|
minWidth: 220,
|
||||||
|
flex: 2.2,
|
||||||
|
cellRendererParams: {
|
||||||
|
suppressCount: true
|
||||||
|
},
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (params.data?.code && params.data?.name) return `${params.data.code} ${params.data.name}`
|
||||||
|
const key = String(params.node?.key || '')
|
||||||
|
const dictItem = (props.dict as DictSource)[key]
|
||||||
|
return dictItem ? `${dictItem.code} ${dictItem.name}` : ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveToIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const payload: GridState = {
|
||||||
|
detailRows: JSON.parse(JSON.stringify(detailRows.value))
|
||||||
|
}
|
||||||
|
await localforage.setItem(props.storageKey, payload)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('saveToIndexedDB failed:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFromIndexedDB = async () => {
|
||||||
|
try {
|
||||||
|
const data = await localforage.getItem<GridState>(props.storageKey)
|
||||||
|
if (data?.detailRows) {
|
||||||
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
detailRows.value = buildDefaultRows()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleCellValueChanged = () => {
|
||||||
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
|
gridPersistTimer = setTimeout(() => {
|
||||||
|
void saveToIndexedDB()
|
||||||
|
}, 500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const processCellForClipboard = (params: any) => {
|
||||||
|
if (Array.isArray(params.value)) {
|
||||||
|
return JSON.stringify(params.value)
|
||||||
|
}
|
||||||
|
return params.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const processCellFromClipboard = (params: any) => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(params.value)
|
||||||
|
if (Array.isArray(parsed)) return parsed
|
||||||
|
} catch (_error) {
|
||||||
|
return params.value
|
||||||
|
}
|
||||||
|
return params.value
|
||||||
|
}
|
||||||
|
|
||||||
|
const fitColumnsToGrid = () => {
|
||||||
|
if (!gridApi.value) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 68 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGridReady = (event: GridReadyEvent<FactorRow>) => {
|
||||||
|
gridApi.value = event.api
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGridSizeChanged = (_event: GridSizeChangedEvent<FactorRow>) => {
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<FactorRow>) => {
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await 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 class="text-xs text-muted-foreground">导入导出</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
|
<AgGridVue
|
||||||
|
:style="{ height: '100%' }"
|
||||||
|
:rowData="detailRows"
|
||||||
|
:columnDefs="columnDefs"
|
||||||
|
:autoGroupColumnDef="autoGroupColumnDef"
|
||||||
|
:gridOptions="gridOptions"
|
||||||
|
:theme="myTheme"
|
||||||
|
:treeData="true"
|
||||||
|
@cell-value-changed="handleCellValueChanged"
|
||||||
|
:suppressColumnVirtualisation="true"
|
||||||
|
:suppressRowVirtualisation="true"
|
||||||
|
:cellSelection="{ handle: { mode: 'range' } }"
|
||||||
|
:enableClipboard="true"
|
||||||
|
:localeText="AG_GRID_LOCALE_CN"
|
||||||
|
:tooltipShowDelay="500"
|
||||||
|
:headerHeight="50"
|
||||||
|
:suppressHorizontalScroll="true"
|
||||||
|
:processCellForClipboard="processCellForClipboard"
|
||||||
|
:processCellFromClipboard="processCellFromClipboard"
|
||||||
|
:undoRedoCellEditing="true"
|
||||||
|
:undoRedoCellEditingLimit="20"
|
||||||
|
@grid-ready="handleGridReady"
|
||||||
|
@grid-size-changed="handleGridSizeChanged"
|
||||||
|
@first-data-rendered="handleFirstDataRendered"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
12
src/components/views/XmMajorFactor.vue
Normal file
12
src/components/views/XmMajorFactor.vue
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { majorList } from '@/sql'
|
||||||
|
import XmFactorGrid from '@/components/views/XmFactorGrid.vue'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<XmFactorGrid
|
||||||
|
title="工程专业系数明细"
|
||||||
|
storage-key="xm-major-factor-v1"
|
||||||
|
:dict="majorList"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { majorList } from '@/sql'
|
import { majorList } from '@/sql'
|
||||||
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
|
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
|
||||||
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
|
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
|
||||||
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||||
@ -46,6 +47,7 @@ const DB_KEY = computed(() => `ht-info-v3-${props.contractId}`)
|
|||||||
const XM_DB_KEY = 'xm-info-v3'
|
const XM_DB_KEY = 'xm-info-v3'
|
||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||||
|
|
||||||
type majorLite = { code: string; name: string }
|
type majorLite = { code: string; name: string }
|
||||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||||
@ -146,11 +148,12 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '造价金额(万元)',
|
headerName: '造价金额(万元)',
|
||||||
field: 'amount',
|
field: 'amount',
|
||||||
minWidth: 170,
|
headerClass: 'ag-right-aligned-header',
|
||||||
|
minWidth: 100,
|
||||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
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 ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params =>
|
'editable-cell-empty': params =>
|
||||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
@ -166,13 +169,13 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
return '点击输入'
|
return '点击输入'
|
||||||
}
|
}
|
||||||
if (params.value == null) return ''
|
if (params.value == null) return ''
|
||||||
return Number(params.value).toFixed(2)
|
return formatThousands(params.value)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '用地面积(亩)',
|
headerName: '用地面积(亩)',
|
||||||
field: 'landArea',
|
field: 'landArea',
|
||||||
minWidth: 170,
|
minWidth: 100,
|
||||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||||
@ -199,10 +202,12 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
|
|
||||||
const autoGroupColumnDef: ColDef = {
|
const autoGroupColumnDef: ColDef = {
|
||||||
headerName: '专业编码以及工程专业名称',
|
headerName: '专业编码以及工程专业名称',
|
||||||
minWidth: 320,
|
minWidth: 200,
|
||||||
pinned: 'left',
|
maxWidth: 300,
|
||||||
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
wrapText: true,
|
||||||
|
autoHeight: true,
|
||||||
|
// cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||||
cellRendererParams: {
|
cellRendererParams: {
|
||||||
suppressCount: true
|
suppressCount: true
|
||||||
},
|
},
|
||||||
@ -212,6 +217,11 @@ const autoGroupColumnDef: ColDef = {
|
|||||||
}
|
}
|
||||||
const nodeId = String(params.value || '')
|
const nodeId = String(params.value || '')
|
||||||
return idLabelMap.get(nodeId) || nodeId
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
|
},
|
||||||
|
tooltipValueGetter: params => {
|
||||||
|
if (params.node?.rowPinned) return '总合计'
|
||||||
|
const nodeId = String(params.value || '')
|
||||||
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -306,19 +316,39 @@ const processCellFromClipboard = (params:any) => {
|
|||||||
}
|
}
|
||||||
return params.value;
|
return params.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fitColumnsToGrid = () => {
|
||||||
|
if (!gridApi.value) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 80 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||||
|
gridApi.value = event.api
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGridSizeChanged = (_event: GridSizeChangedEvent<DetailRow>) => {
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="h-full min-h-0 flex flex-col">
|
<div class="h-full min-h-0 min-w-0 flex flex-col">
|
||||||
|
|
||||||
|
|
||||||
<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 min-w-0 flex-1 flex-col overflow-hidden">
|
||||||
<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="text-xs text-muted-foreground">导入导出</div>
|
<div class="text-xs text-muted-foreground">导入导出</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 min-w-0 w-full flex-1 overflow-hidden">
|
||||||
<AgGridVue
|
<AgGridVue
|
||||||
:style="{ height: '100%' }"
|
:style="{ height: '100%' }"
|
||||||
:rowData="detailRows"
|
:rowData="detailRows"
|
||||||
@ -335,10 +365,14 @@ const processCellFromClipboard = (params:any) => {
|
|||||||
:localeText="AG_GRID_LOCALE_CN"
|
:localeText="AG_GRID_LOCALE_CN"
|
||||||
:tooltipShowDelay="500"
|
:tooltipShowDelay="500"
|
||||||
:headerHeight="50"
|
:headerHeight="50"
|
||||||
|
:suppressHorizontalScroll="true"
|
||||||
:processCellForClipboard="processCellForClipboard"
|
:processCellForClipboard="processCellForClipboard"
|
||||||
:processCellFromClipboard="processCellFromClipboard"
|
:processCellFromClipboard="processCellFromClipboard"
|
||||||
:undoRedoCellEditing="true"
|
:undoRedoCellEditing="true"
|
||||||
:undoRedoCellEditingLimit="20"
|
:undoRedoCellEditingLimit="20"
|
||||||
|
@grid-ready="handleGridReady"
|
||||||
|
@grid-size-changed="handleGridSizeChanged"
|
||||||
|
@first-data-rendered="handleFirstDataRendered"
|
||||||
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -3,33 +3,21 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
|
import { expertList } from '@/sql'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
|
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||||
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
|
|
||||||
interface DictLeaf {
|
|
||||||
id: string
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DictGroup {
|
|
||||||
id: string
|
|
||||||
code: string
|
|
||||||
name: string
|
|
||||||
children: DictLeaf[]
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DetailRow {
|
interface DetailRow {
|
||||||
id: string
|
id: string
|
||||||
groupCode: string
|
expertCode: string
|
||||||
groupName: string
|
expertName: string
|
||||||
majorCode: string
|
laborBudgetUnitPrice: string
|
||||||
majorName: string
|
compositeBudgetUnitPrice: string
|
||||||
laborBudgetUnitPrice: number | null
|
|
||||||
compositeBudgetUnitPrice: number | null
|
|
||||||
adoptedBudgetUnitPrice: number | null
|
adoptedBudgetUnitPrice: number | null
|
||||||
personnelCount: number | null
|
personnelCount: number | null
|
||||||
workdayCount: number | null
|
workdayCount: number | null
|
||||||
@ -74,14 +62,77 @@ const shouldForceDefaultLoad = () => {
|
|||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
|
||||||
|
type ExpertLite = {
|
||||||
|
code: string
|
||||||
|
name: string
|
||||||
|
maxPrice: number | null
|
||||||
|
minPrice: number | null
|
||||||
|
defPrice: number | null
|
||||||
|
manageCoe: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const expertEntries = Object.entries(expertList as Record<string, ExpertLite>)
|
||||||
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
.filter((entry): entry is [string, ExpertLite] => {
|
||||||
|
const item = entry[1]
|
||||||
|
return Boolean(item?.code && item?.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const formatPriceRange = (min: number | null, max: number | null) => {
|
||||||
|
const hasMin = typeof min === 'number' && Number.isFinite(min)
|
||||||
|
const hasMax = typeof max === 'number' && Number.isFinite(max)
|
||||||
|
if (hasMin && hasMax) return `${min}-${max}`
|
||||||
|
if (hasMin) return String(min)
|
||||||
|
if (hasMax) return String(max)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
const idLabelMap = new Map<string, string>()
|
const getCompositeBudgetUnitPriceRange = (expert: ExpertLite) => {
|
||||||
|
if (
|
||||||
|
typeof expert.manageCoe !== 'number' ||
|
||||||
|
!Number.isFinite(expert.manageCoe)
|
||||||
|
) {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
const min = typeof expert.minPrice === 'number' && Number.isFinite(expert.minPrice)
|
||||||
|
? roundTo(toDecimal(expert.minPrice).mul(expert.manageCoe), 2)
|
||||||
|
: null
|
||||||
|
const max = typeof expert.maxPrice === 'number' && Number.isFinite(expert.maxPrice)
|
||||||
|
? roundTo(toDecimal(expert.maxPrice).mul(expert.manageCoe), 2)
|
||||||
|
: null
|
||||||
|
return formatPriceRange(min, max)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultAdoptedBudgetUnitPrice = (expert: ExpertLite) => {
|
||||||
|
if (
|
||||||
|
typeof expert.defPrice !== 'number' ||
|
||||||
|
!Number.isFinite(expert.defPrice) ||
|
||||||
|
typeof expert.manageCoe !== 'number' ||
|
||||||
|
!Number.isFinite(expert.manageCoe)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
|
||||||
|
}
|
||||||
|
|
||||||
const buildDefaultRows = (): DetailRow[] => {
|
const buildDefaultRows = (): DetailRow[] => {
|
||||||
const rows: DetailRow[] = []
|
const rows: DetailRow[] = []
|
||||||
|
for (const [expertId, expert] of expertEntries) {
|
||||||
|
const rowId = `expert-${expertId}`
|
||||||
|
rows.push({
|
||||||
|
id: rowId,
|
||||||
|
expertCode: expert.code,
|
||||||
|
expertName: expert.name,
|
||||||
|
laborBudgetUnitPrice: formatPriceRange(expert.minPrice, expert.maxPrice),
|
||||||
|
compositeBudgetUnitPrice: getCompositeBudgetUnitPriceRange(expert),
|
||||||
|
adoptedBudgetUnitPrice: getDefaultAdoptedBudgetUnitPrice(expert),
|
||||||
|
personnelCount: null,
|
||||||
|
workdayCount: null,
|
||||||
|
serviceBudget: null,
|
||||||
|
remark: '',
|
||||||
|
path: [rowId]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return rows
|
return rows
|
||||||
}
|
}
|
||||||
@ -98,10 +149,6 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
laborBudgetUnitPrice:
|
|
||||||
typeof fromDb.laborBudgetUnitPrice === 'number' ? fromDb.laborBudgetUnitPrice : null,
|
|
||||||
compositeBudgetUnitPrice:
|
|
||||||
typeof fromDb.compositeBudgetUnitPrice === 'number' ? fromDb.compositeBudgetUnitPrice : null,
|
|
||||||
adoptedBudgetUnitPrice:
|
adoptedBudgetUnitPrice:
|
||||||
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
typeof fromDb.adoptedBudgetUnitPrice === 'number' ? fromDb.adoptedBudgetUnitPrice : null,
|
||||||
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
personnelCount: typeof fromDb.personnelCount === 'number' ? fromDb.personnelCount : null,
|
||||||
@ -118,6 +165,17 @@ const parseNumberOrNull = (value: unknown) => {
|
|||||||
return Number.isFinite(v) ? v : null
|
return Number.isFinite(v) ? v : null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseNonNegativeIntegerOrNull = (value: unknown) => {
|
||||||
|
if (value === '' || value == null) return null
|
||||||
|
if (typeof value === 'number') {
|
||||||
|
return Number.isInteger(value) && value >= 0 ? value : null
|
||||||
|
}
|
||||||
|
const normalized = String(value).trim()
|
||||||
|
if (!/^\d+$/.test(normalized)) return null
|
||||||
|
const v = Number(normalized)
|
||||||
|
return Number.isSafeInteger(v) ? v : null
|
||||||
|
}
|
||||||
|
|
||||||
const formatEditableNumber = (params: any) => {
|
const formatEditableNumber = (params: any) => {
|
||||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
return '点击输入'
|
return '点击输入'
|
||||||
@ -126,6 +184,31 @@ const formatEditableNumber = (params: any) => {
|
|||||||
return Number(params.value).toFixed(2)
|
return Number(params.value).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const formatEditableInteger = (params: any) => {
|
||||||
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return String(Number(params.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcServiceBudget = (row: DetailRow | undefined) => {
|
||||||
|
const adopted = row?.adoptedBudgetUnitPrice
|
||||||
|
const personnel = row?.personnelCount
|
||||||
|
const workday = row?.workdayCount
|
||||||
|
if (
|
||||||
|
typeof adopted !== 'number' ||
|
||||||
|
!Number.isFinite(adopted) ||
|
||||||
|
typeof personnel !== 'number' ||
|
||||||
|
!Number.isFinite(personnel) ||
|
||||||
|
typeof workday !== 'number' ||
|
||||||
|
!Number.isFinite(workday)
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return roundTo(toDecimal(adopted).mul(personnel).mul(workday), 2)
|
||||||
|
}
|
||||||
|
|
||||||
const editableNumberCol = <K extends keyof DetailRow>(
|
const editableNumberCol = <K extends keyof DetailRow>(
|
||||||
field: K,
|
field: K,
|
||||||
headerName: string,
|
headerName: string,
|
||||||
@ -146,29 +229,96 @@ const editableNumberCol = <K extends keyof DetailRow>(
|
|||||||
...extra
|
...extra
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const editableMoneyCol = <K extends keyof DetailRow>(
|
||||||
|
field: K,
|
||||||
|
headerName: string,
|
||||||
|
extra: Partial<ColDef<DetailRow>> = {}
|
||||||
|
): ColDef<DetailRow> => ({
|
||||||
|
headerName,
|
||||||
|
field,
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
|
minWidth: 150,
|
||||||
|
flex: 1,
|
||||||
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||||
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||||
|
cellClassRules: {
|
||||||
|
'editable-cell-empty': params =>
|
||||||
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
|
},
|
||||||
|
valueParser: params => parseNumberOrNull(params.newValue),
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return formatThousands(params.value)
|
||||||
|
},
|
||||||
|
...extra
|
||||||
|
})
|
||||||
|
|
||||||
|
const readonlyTextCol = <K extends keyof DetailRow>(
|
||||||
|
field: K,
|
||||||
|
headerName: string,
|
||||||
|
extra: Partial<ColDef<DetailRow>> = {}
|
||||||
|
): ColDef<DetailRow> => ({
|
||||||
|
headerName,
|
||||||
|
field,
|
||||||
|
minWidth: 170,
|
||||||
|
flex: 1,
|
||||||
|
editable: false,
|
||||||
|
valueFormatter: params => params.value || '',
|
||||||
|
...extra
|
||||||
|
})
|
||||||
|
|
||||||
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
||||||
|
{
|
||||||
|
headerName: '编码',
|
||||||
|
field: 'expertCode',
|
||||||
|
minWidth: 120,
|
||||||
|
width: 140,
|
||||||
|
pinned: 'left',
|
||||||
|
colSpan: params => (params.node?.rowPinned ? 2 : 1),
|
||||||
|
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
|
||||||
|
},
|
||||||
{
|
{
|
||||||
headerName: '人员名称',
|
headerName: '人员名称',
|
||||||
|
field: 'expertName',
|
||||||
minWidth: 200,
|
minWidth: 200,
|
||||||
width: 220,
|
width: 220,
|
||||||
pinned: 'left',
|
pinned: 'left',
|
||||||
valueGetter: params => {
|
tooltipField: 'expertName',
|
||||||
if (params.node?.rowPinned) return ''
|
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||||
return params.node?.group ? params.data?.groupName || '' : params.data?.majorName || ''
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '预算参考单价',
|
headerName: '预算参考单价',
|
||||||
marryChildren: true,
|
marryChildren: true,
|
||||||
children: [
|
children: [
|
||||||
editableNumberCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
|
readonlyTextCol('laborBudgetUnitPrice', '人工预算单价(元/工日)'),
|
||||||
editableNumberCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
|
readonlyTextCol('compositeBudgetUnitPrice', '综合预算单价(元/工日)')
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
editableNumberCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
|
editableMoneyCol('adoptedBudgetUnitPrice', '预算采用单价(元/工日)'),
|
||||||
editableNumberCol('personnelCount', '人员数量(人)', { aggFunc: decimalAggSum }),
|
editableNumberCol('personnelCount', '人员数量(人)', {
|
||||||
|
aggFunc: decimalAggSum,
|
||||||
|
valueParser: params => parseNonNegativeIntegerOrNull(params.newValue),
|
||||||
|
valueFormatter: formatEditableInteger
|
||||||
|
}),
|
||||||
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
|
editableNumberCol('workdayCount', '工日数量(工日)', { aggFunc: decimalAggSum }),
|
||||||
editableNumberCol('serviceBudget', '服务预算(元)', { aggFunc: decimalAggSum }),
|
{
|
||||||
|
headerName: '服务预算(元)',
|
||||||
|
field: 'serviceBudget',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
|
minWidth: 150,
|
||||||
|
flex: 1,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
|
editable: false,
|
||||||
|
aggFunc: decimalAggSum,
|
||||||
|
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceBudget ?? null : calcServiceBudget(params.data)),
|
||||||
|
valueFormatter: params => {
|
||||||
|
if (params.value == null || params.value === '') return ''
|
||||||
|
return formatThousands(params.value)
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
headerName: '说明',
|
headerName: '说明',
|
||||||
field: 'remark',
|
field: 'remark',
|
||||||
@ -192,39 +342,18 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
const autoGroupColumnDef: ColDef = {
|
|
||||||
headerName: '编码',
|
|
||||||
minWidth: 150,
|
|
||||||
pinned: 'left',
|
|
||||||
width: 160,
|
|
||||||
|
|
||||||
cellRendererParams: {
|
|
||||||
suppressCount: true
|
|
||||||
},
|
|
||||||
valueFormatter: params => {
|
|
||||||
if (params.node?.rowPinned) {
|
|
||||||
return '总合计'
|
|
||||||
}
|
|
||||||
const nodeId = String(params.value || '')
|
|
||||||
const label = idLabelMap.get(nodeId) || nodeId
|
|
||||||
return label.includes(' ') ? label.split(' ')[0] : label
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
|
||||||
|
|
||||||
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
|
||||||
|
|
||||||
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => row.serviceBudget))
|
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => calcServiceBudget(row)))
|
||||||
const pinnedTopRowData = computed(() => [
|
const pinnedTopRowData = computed(() => [
|
||||||
{
|
{
|
||||||
id: 'pinned-total-row',
|
id: 'pinned-total-row',
|
||||||
groupCode: '',
|
expertCode: '总合计',
|
||||||
groupName: '',
|
expertName: '',
|
||||||
majorCode: '',
|
laborBudgetUnitPrice: '',
|
||||||
majorName: '',
|
compositeBudgetUnitPrice: '',
|
||||||
laborBudgetUnitPrice: null,
|
|
||||||
compositeBudgetUnitPrice: null,
|
|
||||||
adoptedBudgetUnitPrice: null,
|
adoptedBudgetUnitPrice: null,
|
||||||
personnelCount: totalPersonnelCount.value,
|
personnelCount: totalPersonnelCount.value,
|
||||||
workdayCount: totalWorkdayCount.value,
|
workdayCount: totalWorkdayCount.value,
|
||||||
@ -244,6 +373,15 @@ const saveToIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
console.log('Saving to IndexedDB:', payload)
|
console.log('Saving to IndexedDB:', payload)
|
||||||
await localforage.setItem(DB_KEY.value, payload)
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
const synced = await syncPricingTotalToZxFw({
|
||||||
|
contractId: props.contractId,
|
||||||
|
serviceId: props.serviceId,
|
||||||
|
field: 'hourly',
|
||||||
|
value: totalServiceBudget.value
|
||||||
|
})
|
||||||
|
if (synced) {
|
||||||
|
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
}
|
}
|
||||||
@ -315,6 +453,12 @@ const processCellFromClipboard = (params: any) => {
|
|||||||
}
|
}
|
||||||
return params.value;
|
return params.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGridReady = (params: any) => {
|
||||||
|
const w = window as any
|
||||||
|
if (!w.__agGridApis) w.__agGridApis = {}
|
||||||
|
w.__agGridApis = params.api
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -329,7 +473,8 @@ const processCellFromClipboard = (params: any) => {
|
|||||||
|
|
||||||
<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"
|
||||||
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
|
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="myTheme" :treeData="false"
|
||||||
|
@grid-ready="handleGridReady"
|
||||||
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
||||||
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
||||||
@ -339,5 +484,3 @@ const processCellFromClipboard = (params: any) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -3,10 +3,13 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
import type { ColDef } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
|
import { getBasicFeeFromScale, majorList } from '@/sql'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||||
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||||
|
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||||
@ -53,6 +56,25 @@ const HT_DB_KEY = computed(() => `ht-info-v3-${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()
|
||||||
|
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
|
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
|
let factorDefaultsLoaded = false
|
||||||
|
|
||||||
|
const getDefaultConsultCategoryFactor = () =>
|
||||||
|
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
||||||
|
|
||||||
|
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
|
||||||
|
|
||||||
|
const ensureFactorDefaultsLoaded = async () => {
|
||||||
|
if (factorDefaultsLoaded) return
|
||||||
|
const [consultMap, majorMap] = await Promise.all([
|
||||||
|
loadConsultCategoryFactorMap(),
|
||||||
|
loadMajorFactorMap()
|
||||||
|
])
|
||||||
|
consultCategoryFactorMap.value = consultMap
|
||||||
|
majorFactorMap.value = majorMap
|
||||||
|
factorDefaultsLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
const shouldSkipPersist = () => {
|
const shouldSkipPersist = () => {
|
||||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
||||||
@ -74,12 +96,6 @@ const shouldForceDefaultLoad = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
type serviceLite = { defCoe: number | null }
|
|
||||||
const defaultConsultCategoryFactor = computed<number | null>(() => {
|
|
||||||
const service = (serviceList as Record<string, serviceLite | undefined>)[String(props.serviceId)]
|
|
||||||
return typeof service?.defCoe === 'number' && Number.isFinite(service.defCoe) ? service.defCoe : null
|
|
||||||
})
|
|
||||||
|
|
||||||
type majorLite = { code: string; name: string; defCoe: number | null }
|
type majorLite = { code: string; name: string; defCoe: number | null }
|
||||||
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
||||||
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
@ -88,11 +104,6 @@ const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
|||||||
return Boolean(item?.code && item?.name)
|
return Boolean(item?.code && item?.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
const getDefaultMajorFactorById = (id: string): number | null => {
|
|
||||||
const major = (majorList as Record<string, majorLite | undefined>)[id]
|
|
||||||
return typeof major?.defCoe === 'number' && Number.isFinite(major.defCoe) ? major.defCoe : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailDict: DictGroup[] = (() => {
|
const detailDict: DictGroup[] = (() => {
|
||||||
const groupMap = new Map<string, DictGroup>()
|
const groupMap = new Map<string, DictGroup>()
|
||||||
const groupOrder: string[] = []
|
const groupOrder: string[] = []
|
||||||
@ -154,7 +165,7 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
majorName: child.name,
|
majorName: child.name,
|
||||||
amount: null,
|
amount: null,
|
||||||
benchmarkBudget: null,
|
benchmarkBudget: null,
|
||||||
consultCategoryFactor: defaultConsultCategoryFactor.value,
|
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
||||||
majorFactor: getDefaultMajorFactorById(child.id),
|
majorFactor: getDefaultMajorFactorById(child.id),
|
||||||
budgetFee: null,
|
budgetFee: null,
|
||||||
remark: '',
|
remark: '',
|
||||||
@ -187,7 +198,7 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] =>
|
|||||||
? fromDb.consultCategoryFactor
|
? fromDb.consultCategoryFactor
|
||||||
: hasConsultCategoryFactor
|
: hasConsultCategoryFactor
|
||||||
? null
|
? null
|
||||||
: defaultConsultCategoryFactor.value,
|
: getDefaultConsultCategoryFactor(),
|
||||||
majorFactor:
|
majorFactor:
|
||||||
typeof fromDb.majorFactor === 'number'
|
typeof fromDb.majorFactor === 'number'
|
||||||
? fromDb.majorFactor
|
? fromDb.majorFactor
|
||||||
@ -216,8 +227,9 @@ const formatEditableNumber = (params: any) => {
|
|||||||
|
|
||||||
const formatConsultCategoryFactor = (params: any) => {
|
const formatConsultCategoryFactor = (params: any) => {
|
||||||
if (params.node?.group) {
|
if (params.node?.group) {
|
||||||
if (defaultConsultCategoryFactor.value == null) return ''
|
const v = getDefaultConsultCategoryFactor()
|
||||||
return Number(defaultConsultCategoryFactor.value).toFixed(2)
|
if (v == null) return ''
|
||||||
|
return Number(v).toFixed(2)
|
||||||
}
|
}
|
||||||
return formatEditableNumber(params)
|
return formatEditableNumber(params)
|
||||||
}
|
}
|
||||||
@ -232,9 +244,17 @@ const formatMajorFactor = (params: any) => {
|
|||||||
return formatEditableNumber(params)
|
return formatEditableNumber(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatReadonlyNumber = (params: any) => {
|
const formatEditableMoney = (params: any) => {
|
||||||
|
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||||
|
return '点击输入'
|
||||||
|
}
|
||||||
|
if (params.value == null) return ''
|
||||||
|
return formatThousands(params.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatReadonlyMoney = (params: any) => {
|
||||||
if (params.value == null || params.value === '') return ''
|
if (params.value == null || params.value === '') return ''
|
||||||
return roundTo(params.value, 2).toFixed(2)
|
return formatThousands(roundTo(params.value, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
|
const getBenchmarkBudgetByAmount = (row?: Pick<DetailRow, 'amount'>) => {
|
||||||
@ -252,34 +272,38 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '造价金额(万元)',
|
headerName: '造价金额(万元)',
|
||||||
field: 'amount',
|
field: 'amount',
|
||||||
minWidth: 170,
|
headerClass: 'ag-right-aligned-header',
|
||||||
flex: 1,
|
minWidth: 100,
|
||||||
|
flex: 2,
|
||||||
|
|
||||||
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 ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params =>
|
'editable-cell-empty': params =>
|
||||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
},
|
},
|
||||||
aggFunc: decimalAggSum,
|
aggFunc: decimalAggSum,
|
||||||
valueParser: params => parseNumberOrNull(params.newValue),
|
valueParser: params => parseNumberOrNull(params.newValue),
|
||||||
valueFormatter: formatEditableNumber
|
valueFormatter: formatEditableMoney
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '基准预算(元)',
|
headerName: '基准预算(元)',
|
||||||
field: 'benchmarkBudget',
|
field: 'benchmarkBudget',
|
||||||
minWidth: 170,
|
headerClass: 'ag-right-aligned-header',
|
||||||
flex: 1,
|
minWidth: 100,
|
||||||
|
flex: 2,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
aggFunc: decimalAggSum,
|
aggFunc: decimalAggSum,
|
||||||
valueGetter: params => getBenchmarkBudgetByAmount(params.data),
|
valueGetter: params => getBenchmarkBudgetByAmount(params.data),
|
||||||
valueParser: params => parseNumberOrNull(params.newValue),
|
valueParser: params => parseNumberOrNull(params.newValue),
|
||||||
valueFormatter: formatReadonlyNumber
|
valueFormatter: formatReadonlyMoney
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '咨询分类系数',
|
headerName: '咨询分类系数',
|
||||||
field: 'consultCategoryFactor',
|
field: 'consultCategoryFactor',
|
||||||
minWidth: 150,
|
width: 80,
|
||||||
flex: 1,
|
minWidth: 70,
|
||||||
|
maxWidth: 90,
|
||||||
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: {
|
||||||
@ -292,8 +316,9 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '专业系数',
|
headerName: '专业系数',
|
||||||
field: 'majorFactor',
|
field: 'majorFactor',
|
||||||
minWidth: 130,
|
width: 80,
|
||||||
flex: 1,
|
minWidth: 70,
|
||||||
|
maxWidth: 90,
|
||||||
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: {
|
||||||
@ -306,12 +331,14 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '预算费用',
|
headerName: '预算费用',
|
||||||
field: 'budgetFee',
|
field: 'budgetFee',
|
||||||
minWidth: 150,
|
headerClass: 'ag-right-aligned-header',
|
||||||
flex: 1,
|
minWidth: 100,
|
||||||
|
flex:2,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
aggFunc: decimalAggSum,
|
aggFunc: decimalAggSum,
|
||||||
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
|
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
|
||||||
valueParser: params => parseNumberOrNull(params.newValue),
|
valueParser: params => parseNumberOrNull(params.newValue),
|
||||||
valueFormatter: formatReadonlyNumber
|
valueFormatter: formatReadonlyMoney
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '说明',
|
headerName: '说明',
|
||||||
@ -338,10 +365,13 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
|
|
||||||
const autoGroupColumnDef: ColDef = {
|
const autoGroupColumnDef: ColDef = {
|
||||||
headerName: '专业编码以及工程专业名称',
|
headerName: '专业编码以及工程专业名称',
|
||||||
minWidth: 320,
|
minWidth: 250,
|
||||||
pinned: 'left',
|
pinned: 'left',
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
// wrapText: true,
|
||||||
|
// cellStyle: { whiteSpace: 'normal', lineHeight: '1.5', padding: '2px' },
|
||||||
|
|
||||||
|
// autoHeight: true,
|
||||||
cellRendererParams: {
|
cellRendererParams: {
|
||||||
suppressCount: true
|
suppressCount: true
|
||||||
},
|
},
|
||||||
@ -351,6 +381,11 @@ const autoGroupColumnDef: ColDef = {
|
|||||||
}
|
}
|
||||||
const nodeId = String(params.value || '')
|
const nodeId = String(params.value || '')
|
||||||
return idLabelMap.get(nodeId) || nodeId
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
|
},
|
||||||
|
tooltipValueGetter: params => {
|
||||||
|
if (params.node?.rowPinned) return '总合计'
|
||||||
|
const nodeId = String(params.value || '')
|
||||||
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -387,6 +422,15 @@ const saveToIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
console.log('Saving to IndexedDB:', payload)
|
console.log('Saving to IndexedDB:', payload)
|
||||||
await localforage.setItem(DB_KEY.value, payload)
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
const synced = await syncPricingTotalToZxFw({
|
||||||
|
contractId: props.contractId,
|
||||||
|
serviceId: props.serviceId,
|
||||||
|
field: 'investScale',
|
||||||
|
value: totalBudgetFee.value
|
||||||
|
})
|
||||||
|
if (synced) {
|
||||||
|
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
}
|
}
|
||||||
@ -394,6 +438,7 @@ const saveToIndexedDB = async () => {
|
|||||||
|
|
||||||
const loadFromIndexedDB = async () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
|
await ensureFactorDefaultsLoaded()
|
||||||
if (shouldForceDefaultLoad()) {
|
if (shouldForceDefaultLoad()) {
|
||||||
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 ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
|
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
|
||||||
@ -465,6 +510,8 @@ const processCellFromClipboard = (params: any) => {
|
|||||||
}
|
}
|
||||||
return params.value;
|
return params.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -3,10 +3,13 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
import type { ColDef } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { getBasicFeeFromScale, majorList, serviceList } from '@/sql'
|
import { getBasicFeeFromScale, majorList } from '@/sql'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
import { addNumbers, decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||||
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||||
|
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||||
@ -54,13 +57,26 @@ const HT_DB_KEY = computed(() => `ht-info-v3-${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()
|
||||||
|
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
|
const majorFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
|
let factorDefaultsLoaded = false
|
||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
type serviceLite = { defCoe: number | null }
|
const getDefaultConsultCategoryFactor = () =>
|
||||||
const defaultConsultCategoryFactor = computed<number | null>(() => {
|
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
||||||
const service = (serviceList as Record<string, serviceLite | undefined>)[String(props.serviceId)]
|
|
||||||
return typeof service?.defCoe === 'number' && Number.isFinite(service.defCoe) ? service.defCoe : null
|
const getDefaultMajorFactorById = (id: string): number | null => majorFactorMap.value.get(id) ?? null
|
||||||
})
|
|
||||||
|
const ensureFactorDefaultsLoaded = async () => {
|
||||||
|
if (factorDefaultsLoaded) return
|
||||||
|
const [consultMap, majorMap] = await Promise.all([
|
||||||
|
loadConsultCategoryFactorMap(),
|
||||||
|
loadMajorFactorMap()
|
||||||
|
])
|
||||||
|
consultCategoryFactorMap.value = consultMap
|
||||||
|
majorFactorMap.value = majorMap
|
||||||
|
factorDefaultsLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
const shouldForceDefaultLoad = () => {
|
const shouldForceDefaultLoad = () => {
|
||||||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
||||||
@ -89,11 +105,6 @@ const serviceEntries = Object.entries(majorList as Record<string, majorLite>)
|
|||||||
return Boolean(item?.code && item?.name)
|
return Boolean(item?.code && item?.name)
|
||||||
})
|
})
|
||||||
|
|
||||||
const getDefaultMajorFactorById = (id: string): number | null => {
|
|
||||||
const major = (majorList as Record<string, majorLite | undefined>)[id]
|
|
||||||
return typeof major?.defCoe === 'number' && Number.isFinite(major.defCoe) ? major.defCoe : null
|
|
||||||
}
|
|
||||||
|
|
||||||
const detailDict: DictGroup[] = (() => {
|
const detailDict: DictGroup[] = (() => {
|
||||||
const groupMap = new Map<string, DictGroup>()
|
const groupMap = new Map<string, DictGroup>()
|
||||||
const groupOrder: string[] = []
|
const groupOrder: string[] = []
|
||||||
@ -156,7 +167,7 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
amount: null,
|
amount: null,
|
||||||
landArea: null,
|
landArea: null,
|
||||||
benchmarkBudget: null,
|
benchmarkBudget: null,
|
||||||
consultCategoryFactor: defaultConsultCategoryFactor.value,
|
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
||||||
majorFactor: getDefaultMajorFactorById(child.id),
|
majorFactor: getDefaultMajorFactorById(child.id),
|
||||||
budgetFee: null,
|
budgetFee: null,
|
||||||
remark: '',
|
remark: '',
|
||||||
@ -190,7 +201,7 @@ const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): DetailRow[] =>
|
|||||||
? fromDb.consultCategoryFactor
|
? fromDb.consultCategoryFactor
|
||||||
: hasConsultCategoryFactor
|
: hasConsultCategoryFactor
|
||||||
? null
|
? null
|
||||||
: defaultConsultCategoryFactor.value,
|
: getDefaultConsultCategoryFactor(),
|
||||||
majorFactor:
|
majorFactor:
|
||||||
typeof fromDb.majorFactor === 'number'
|
typeof fromDb.majorFactor === 'number'
|
||||||
? fromDb.majorFactor
|
? fromDb.majorFactor
|
||||||
@ -219,8 +230,9 @@ const formatEditableNumber = (params: any) => {
|
|||||||
|
|
||||||
const formatConsultCategoryFactor = (params: any) => {
|
const formatConsultCategoryFactor = (params: any) => {
|
||||||
if (params.node?.group) {
|
if (params.node?.group) {
|
||||||
if (defaultConsultCategoryFactor.value == null) return ''
|
const v = getDefaultConsultCategoryFactor()
|
||||||
return Number(defaultConsultCategoryFactor.value).toFixed(2)
|
if (v == null) return ''
|
||||||
|
return Number(v).toFixed(2)
|
||||||
}
|
}
|
||||||
return formatEditableNumber(params)
|
return formatEditableNumber(params)
|
||||||
}
|
}
|
||||||
@ -235,9 +247,9 @@ const formatMajorFactor = (params: any) => {
|
|||||||
return formatEditableNumber(params)
|
return formatEditableNumber(params)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatReadonlyNumber = (params: any) => {
|
const formatReadonlyMoney = (params: any) => {
|
||||||
if (params.value == null || params.value === '') return ''
|
if (params.value == null || params.value === '') return ''
|
||||||
return roundTo(params.value, 2).toFixed(2)
|
return formatThousands(roundTo(params.value, 2))
|
||||||
}
|
}
|
||||||
|
|
||||||
const getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) => {
|
const getBenchmarkBudgetByLandArea = (row?: Pick<DetailRow, 'landArea'>) => {
|
||||||
@ -279,18 +291,21 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '基准预算(元)',
|
headerName: '基准预算(元)',
|
||||||
field: 'benchmarkBudget',
|
field: 'benchmarkBudget',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 170,
|
minWidth: 170,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
aggFunc: decimalAggSum,
|
aggFunc: decimalAggSum,
|
||||||
valueGetter: params => getBenchmarkBudgetByLandArea(params.data),
|
valueGetter: params => getBenchmarkBudgetByLandArea(params.data),
|
||||||
valueParser: params => parseNumberOrNull(params.newValue),
|
valueParser: params => parseNumberOrNull(params.newValue),
|
||||||
valueFormatter: formatReadonlyNumber
|
valueFormatter: formatReadonlyMoney
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '咨询分类系数',
|
headerName: '咨询分类系数',
|
||||||
field: 'consultCategoryFactor',
|
field: 'consultCategoryFactor',
|
||||||
minWidth: 150,
|
width: 80,
|
||||||
flex: 1,
|
minWidth: 70,
|
||||||
|
maxWidth: 90,
|
||||||
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: {
|
||||||
@ -303,8 +318,9 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '专业系数',
|
headerName: '专业系数',
|
||||||
field: 'majorFactor',
|
field: 'majorFactor',
|
||||||
minWidth: 130,
|
width: 80,
|
||||||
flex: 1,
|
minWidth: 70,
|
||||||
|
maxWidth: 90,
|
||||||
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: {
|
||||||
@ -317,12 +333,14 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '预算费用',
|
headerName: '预算费用',
|
||||||
field: 'budgetFee',
|
field: 'budgetFee',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
aggFunc: decimalAggSum,
|
aggFunc: decimalAggSum,
|
||||||
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
|
valueGetter: params => (params.node?.rowPinned ? params.data?.budgetFee ?? null : getBudgetFee(params.data)),
|
||||||
valueParser: params => parseNumberOrNull(params.newValue),
|
valueParser: params => parseNumberOrNull(params.newValue),
|
||||||
valueFormatter: formatReadonlyNumber
|
valueFormatter: formatReadonlyMoney
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '说明',
|
headerName: '说明',
|
||||||
@ -362,6 +380,11 @@ const autoGroupColumnDef: ColDef = {
|
|||||||
}
|
}
|
||||||
const nodeId = String(params.value || '')
|
const nodeId = String(params.value || '')
|
||||||
return idLabelMap.get(nodeId) || nodeId
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
|
},
|
||||||
|
tooltipValueGetter: params => {
|
||||||
|
if (params.node?.rowPinned) return '总合计'
|
||||||
|
const nodeId = String(params.value || '')
|
||||||
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -402,6 +425,15 @@ const saveToIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
console.log('Saving to IndexedDB:', payload)
|
console.log('Saving to IndexedDB:', payload)
|
||||||
await localforage.setItem(DB_KEY.value, payload)
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
const synced = await syncPricingTotalToZxFw({
|
||||||
|
contractId: props.contractId,
|
||||||
|
serviceId: props.serviceId,
|
||||||
|
field: 'landScale',
|
||||||
|
value: totalBudgetFee.value
|
||||||
|
})
|
||||||
|
if (synced) {
|
||||||
|
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
}
|
}
|
||||||
@ -409,6 +441,7 @@ const saveToIndexedDB = async () => {
|
|||||||
|
|
||||||
const loadFromIndexedDB = async () => {
|
const loadFromIndexedDB = async () => {
|
||||||
try {
|
try {
|
||||||
|
await ensureFactorDefaultsLoaded()
|
||||||
if (shouldForceDefaultLoad()) {
|
if (shouldForceDefaultLoad()) {
|
||||||
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 ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
|
detailRows.value = htData?.detailRows ? mergeWithDictRows(htData.detailRows) : buildDefaultRows()
|
||||||
@ -480,6 +513,8 @@ const processCellFromClipboard = (params: any) => {
|
|||||||
}
|
}
|
||||||
return params.value;
|
return params.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@ -3,10 +3,13 @@ import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
|||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
import type { ColDef } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { serviceList, taskList } from '@/sql'
|
import { taskList } from '@/sql'
|
||||||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||||||
import { decimalAggSum, roundTo, toDecimal } from '@/lib/decimal'
|
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||||
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||||||
|
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
|
|
||||||
@ -40,6 +43,17 @@ const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
|||||||
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()
|
||||||
|
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||||||
|
let factorDefaultsLoaded = false
|
||||||
|
|
||||||
|
const getDefaultConsultCategoryFactor = () =>
|
||||||
|
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
||||||
|
|
||||||
|
const ensureFactorDefaultsLoaded = async () => {
|
||||||
|
if (factorDefaultsLoaded) return
|
||||||
|
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap()
|
||||||
|
factorDefaultsLoaded = true
|
||||||
|
}
|
||||||
|
|
||||||
const shouldSkipPersist = () => {
|
const shouldSkipPersist = () => {
|
||||||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
||||||
@ -62,7 +76,6 @@ const shouldForceDefaultLoad = () => {
|
|||||||
|
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
|
||||||
type serviceLite = { defCoe: number | null }
|
|
||||||
type taskLite = {
|
type taskLite = {
|
||||||
serviceID: number
|
serviceID: number
|
||||||
code: string
|
code: string
|
||||||
@ -76,11 +89,6 @@ type taskLite = {
|
|||||||
desc: string | null
|
desc: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConsultCategoryFactor = computed<number | null>(() => {
|
|
||||||
const service = (serviceList as Record<string, serviceLite | undefined>)[String(props.serviceId)]
|
|
||||||
return typeof service?.defCoe === 'number' && Number.isFinite(service.defCoe) ? service.defCoe : null
|
|
||||||
})
|
|
||||||
|
|
||||||
const formatTaskReferenceUnitPrice = (task: taskLite) => {
|
const formatTaskReferenceUnitPrice = (task: taskLite) => {
|
||||||
const unit = task.unit || ''
|
const unit = task.unit || ''
|
||||||
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
|
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
|
||||||
@ -121,7 +129,7 @@ const buildDefaultRows = (): DetailRow[] => {
|
|||||||
budgetReferenceUnitPrice: formatTaskReferenceUnitPrice(task),
|
budgetReferenceUnitPrice: formatTaskReferenceUnitPrice(task),
|
||||||
budgetAdoptedUnitPrice:
|
budgetAdoptedUnitPrice:
|
||||||
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
|
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
|
||||||
consultCategoryFactor: defaultConsultCategoryFactor.value,
|
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
||||||
serviceFee: null,
|
serviceFee: null,
|
||||||
remark: task.desc|| '',
|
remark: task.desc|| '',
|
||||||
path: [rowId]
|
path: [rowId]
|
||||||
@ -193,12 +201,6 @@ const formatEditableNumber = (params: any) => {
|
|||||||
return Number(params.value).toFixed(2)
|
return Number(params.value).toFixed(2)
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatReadonlyNumber = (params: any) => {
|
|
||||||
if (isNoTaskRow(params.data)) return '无'
|
|
||||||
if (params.value == null || params.value === '') return ''
|
|
||||||
return roundTo(params.value, 2).toFixed(2)
|
|
||||||
}
|
|
||||||
|
|
||||||
const spanRowsByTaskName = (params: any) => {
|
const spanRowsByTaskName = (params: any) => {
|
||||||
const rowA = params?.nodeA?.data as DetailRow | undefined
|
const rowA = params?.nodeA?.data as DetailRow | undefined
|
||||||
const rowB = params?.nodeB?.data as DetailRow | undefined
|
const rowB = params?.nodeB?.data as DetailRow | undefined
|
||||||
@ -216,7 +218,8 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
minWidth: 100,
|
minWidth: 100,
|
||||||
width: 120,
|
width: 120,
|
||||||
pinned: 'left',
|
pinned: 'left',
|
||||||
valueFormatter: params => params.value || ''
|
colSpan: params => (params.node?.rowPinned ? 3 : 1),
|
||||||
|
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '名称',
|
headerName: '名称',
|
||||||
@ -227,7 +230,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
autoHeight: true,
|
autoHeight: true,
|
||||||
|
|
||||||
spanRows: true,
|
spanRows: true,
|
||||||
valueFormatter: params => params.value || ''
|
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '预算基数',
|
headerName: '预算基数',
|
||||||
@ -238,7 +241,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
width: 180,
|
width: 180,
|
||||||
pinned: 'left',
|
pinned: 'left',
|
||||||
spanRows: spanRowsByTaskName,
|
spanRows: spanRowsByTaskName,
|
||||||
valueFormatter: params => params.value || ''
|
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '预算参考单价',
|
headerName: '预算参考单价',
|
||||||
@ -250,10 +253,11 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '预算采用单价',
|
headerName: '预算采用单价',
|
||||||
field: 'budgetAdoptedUnitPrice',
|
field: 'budgetAdoptedUnitPrice',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 170,
|
minWidth: 170,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params =>
|
'editable-cell-empty': params =>
|
||||||
!params.node?.group &&
|
!params.node?.group &&
|
||||||
@ -269,7 +273,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
}
|
}
|
||||||
if (params.value == null) return ''
|
if (params.value == null) return ''
|
||||||
const unit = params.data?.unit || ''
|
const unit = params.data?.unit || ''
|
||||||
return `${Number(params.value).toFixed(2)}${unit}`
|
return `${formatThousands(params.value)}${unit}`
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -293,8 +297,9 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '咨询分类系数',
|
headerName: '咨询分类系数',
|
||||||
field: 'consultCategoryFactor',
|
field: 'consultCategoryFactor',
|
||||||
minWidth: 150,
|
width: 80,
|
||||||
flex: 1,
|
minWidth: 70,
|
||||||
|
maxWidth: 90,
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
@ -310,12 +315,18 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '服务费用(元)',
|
headerName: '服务费用(元)',
|
||||||
field: 'serviceFee',
|
field: 'serviceFee',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 150,
|
minWidth: 150,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
editable: false,
|
editable: false,
|
||||||
valueGetter: params => calcServiceFee(params.data),
|
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)),
|
||||||
aggFunc: decimalAggSum,
|
aggFunc: decimalAggSum,
|
||||||
valueFormatter: formatReadonlyNumber
|
valueFormatter: params => {
|
||||||
|
if (isNoTaskRow(params.data)) return '无'
|
||||||
|
if (params.value == null || params.value === '') return ''
|
||||||
|
return formatThousands(roundTo(params.value, 2))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '说明',
|
headerName: '说明',
|
||||||
@ -343,6 +354,27 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
|
||||||
|
|
||||||
|
const totalServiceFee = computed(() => sumByNumber(detailRows.value, row => calcServiceFee(row)))
|
||||||
|
const pinnedTopRowData = computed(() => [
|
||||||
|
{
|
||||||
|
id: 'pinned-total-row',
|
||||||
|
taskCode: '总合计',
|
||||||
|
taskName: '',
|
||||||
|
unit: '',
|
||||||
|
conversion: null,
|
||||||
|
workload: totalWorkload.value,
|
||||||
|
budgetBase: '',
|
||||||
|
budgetReferenceUnitPrice: '',
|
||||||
|
budgetAdoptedUnitPrice: null,
|
||||||
|
consultCategoryFactor: null,
|
||||||
|
serviceFee: totalServiceFee.value,
|
||||||
|
remark: '',
|
||||||
|
path: ['TOTAL']
|
||||||
|
}
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const saveToIndexedDB = async () => {
|
const saveToIndexedDB = async () => {
|
||||||
@ -354,6 +386,15 @@ const saveToIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
console.log('Saving to IndexedDB:', payload)
|
console.log('Saving to IndexedDB:', payload)
|
||||||
await localforage.setItem(DB_KEY.value, payload)
|
await localforage.setItem(DB_KEY.value, payload)
|
||||||
|
const synced = await syncPricingTotalToZxFw({
|
||||||
|
contractId: props.contractId,
|
||||||
|
serviceId: props.serviceId,
|
||||||
|
field: 'workload',
|
||||||
|
value: totalServiceFee.value
|
||||||
|
})
|
||||||
|
if (synced) {
|
||||||
|
pricingPaneReloadStore.markReload(props.contractId, ZXFW_RELOAD_SERVICE_KEY)
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
}
|
}
|
||||||
@ -366,6 +407,8 @@ const loadFromIndexedDB = async () => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await ensureFactorDefaultsLoaded()
|
||||||
|
|
||||||
if (shouldForceDefaultLoad()) {
|
if (shouldForceDefaultLoad()) {
|
||||||
detailRows.value = buildDefaultRows()
|
detailRows.value = buildDefaultRows()
|
||||||
return
|
return
|
||||||
@ -430,6 +473,7 @@ const processCellFromClipboard = (params: any) => {
|
|||||||
}
|
}
|
||||||
return params.value;
|
return params.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
const mydiyTheme = myTheme.withParams({
|
const mydiyTheme = myTheme.withParams({
|
||||||
rowBorder: {
|
rowBorder: {
|
||||||
style: "solid",
|
style: "solid",
|
||||||
@ -455,15 +499,15 @@ const mydiyTheme = myTheme.withParams({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isWorkloadMethodApplicable" class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
<div v-if="isWorkloadMethodApplicable" class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||||||
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows"
|
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
|
||||||
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
|
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
|
||||||
:enableCellSpan="true"
|
:enableCellSpan="true"
|
||||||
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
||||||
:suppressRowVirtualisation="true"
|
:suppressRowVirtualisation="true"
|
||||||
|
|
||||||
|
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||||
|
|
||||||
|
|
||||||
:enableClipboard="true"
|
|
||||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
||||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||||
@ -480,6 +524,3 @@ const mydiyTheme = myTheme.withParams({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<!-- :rowSelection="'multiple'"
|
|
||||||
:enableClickSelection="false" -->
|
|
||||||
<!-- :suppressRowTransform="true" -->
|
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef } from 'ag-grid-community'
|
import type { ColDef, FirstDataRenderedEvent, GridApi, GridReadyEvent, GridSizeChangedEvent } from 'ag-grid-community'
|
||||||
import localforage from 'localforage'
|
import localforage from 'localforage'
|
||||||
import { majorList } from '@/sql'
|
import { majorList } from '@/sql'
|
||||||
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
|
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
|
||||||
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
|
import { decimalAggSum, sumByNumber } from '@/lib/decimal'
|
||||||
|
import { formatThousands } from '@/lib/numberFormat'
|
||||||
|
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||||
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
// 精简的边框配置(细线条+浅灰色,弱化分割线视觉)
|
||||||
@ -43,8 +44,9 @@ interface XmInfoState {
|
|||||||
const DB_KEY = 'xm-info-v3'
|
const DB_KEY = 'xm-info-v3'
|
||||||
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
|
||||||
|
|
||||||
const projectName = ref(DEFAULT_PROJECT_NAME)
|
const projectName = ref('')
|
||||||
const detailRows = ref<DetailRow[]>([])
|
const detailRows = ref<DetailRow[]>([])
|
||||||
|
const gridApi = ref<GridApi<DetailRow> | null>(null)
|
||||||
const rootRef = ref<HTMLElement | null>(null)
|
const rootRef = ref<HTMLElement | null>(null)
|
||||||
const gridSectionRef = ref<HTMLElement | null>(null)
|
const gridSectionRef = ref<HTMLElement | null>(null)
|
||||||
const agGridRef = ref<HTMLElement | null>(null)
|
const agGridRef = ref<HTMLElement | null>(null)
|
||||||
@ -211,11 +213,12 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
{
|
{
|
||||||
headerName: '造价金额(万元)',
|
headerName: '造价金额(万元)',
|
||||||
field: 'amount',
|
field: 'amount',
|
||||||
minWidth: 170,
|
headerClass: 'ag-right-aligned-header',
|
||||||
|
minWidth: 100,
|
||||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
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 ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||||||
cellClassRules: {
|
cellClassRules: {
|
||||||
'editable-cell-empty': params =>
|
'editable-cell-empty': params =>
|
||||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||||
@ -231,13 +234,13 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
return '点击输入'
|
return '点击输入'
|
||||||
}
|
}
|
||||||
if (params.value == null) return ''
|
if (params.value == null) return ''
|
||||||
return Number(params.value).toFixed(2)
|
return formatThousands(params.value)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '用地面积(亩)',
|
headerName: '用地面积(亩)',
|
||||||
field: 'landArea',
|
field: 'landArea',
|
||||||
minWidth: 170,
|
minWidth: 100,
|
||||||
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
flex: 1, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||||
@ -264,8 +267,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
|
|
||||||
const autoGroupColumnDef: ColDef = {
|
const autoGroupColumnDef: ColDef = {
|
||||||
headerName: '专业编码以及工程专业名称',
|
headerName: '专业编码以及工程专业名称',
|
||||||
minWidth: 320,
|
minWidth: 200,
|
||||||
pinned: 'left',
|
|
||||||
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
flex:2, // 核心:开启弹性布局,自动占满剩余空间
|
||||||
|
|
||||||
cellRendererParams: {
|
cellRendererParams: {
|
||||||
@ -277,6 +279,11 @@ const autoGroupColumnDef: ColDef = {
|
|||||||
}
|
}
|
||||||
const nodeId = String(params.value || '')
|
const nodeId = String(params.value || '')
|
||||||
return idLabelMap.get(nodeId) || nodeId
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
|
},
|
||||||
|
tooltipValueGetter: params => {
|
||||||
|
if (params.node?.rowPinned) return '总合计'
|
||||||
|
const nodeId = String(params.value || '')
|
||||||
|
return idLabelMap.get(nodeId) || nodeId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,14 +323,18 @@ const loadFromIndexedDB = async () => {
|
|||||||
try {
|
try {
|
||||||
const data = await localforage.getItem<XmInfoState>(DB_KEY)
|
const data = await localforage.getItem<XmInfoState>(DB_KEY)
|
||||||
if (data) {
|
if (data) {
|
||||||
|
console.log('loaded data:', data)
|
||||||
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
projectName.value = data.projectName || DEFAULT_PROJECT_NAME
|
||||||
detailRows.value = mergeWithDictRows(data.detailRows)
|
detailRows.value = mergeWithDictRows(data.detailRows)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
projectName.value = DEFAULT_PROJECT_NAME
|
||||||
|
|
||||||
detailRows.value = buildDefaultRows()
|
detailRows.value = buildDefaultRows()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('loadFromIndexedDB failed:', error)
|
console.error('loadFromIndexedDB failed:', error)
|
||||||
|
projectName.value = DEFAULT_PROJECT_NAME
|
||||||
|
|
||||||
detailRows.value = buildDefaultRows()
|
detailRows.value = buildDefaultRows()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -385,6 +396,26 @@ const processCellFromClipboard = (params:any) => {
|
|||||||
return params.value;
|
return params.value;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const fitColumnsToGrid = () => {
|
||||||
|
if (!gridApi.value) return
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
gridApi.value?.sizeColumnsToFit({ defaultMinWidth: 80 })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||||
|
gridApi.value = event.api
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleGridSizeChanged = (_event: GridSizeChangedEvent<DetailRow>) => {
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleFirstDataRendered = (_event: FirstDataRenderedEvent<DetailRow>) => {
|
||||||
|
fitColumnsToGrid()
|
||||||
|
}
|
||||||
|
|
||||||
const scrollToGridSection = () => {
|
const scrollToGridSection = () => {
|
||||||
const target = gridSectionRef.value || agGridRef.value
|
const target = gridSectionRef.value || agGridRef.value
|
||||||
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
target?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||||
@ -440,11 +471,14 @@ const scrollToGridSection = () => {
|
|||||||
:localeText="AG_GRID_LOCALE_CN"
|
:localeText="AG_GRID_LOCALE_CN"
|
||||||
:tooltipShowDelay="500"
|
:tooltipShowDelay="500"
|
||||||
:headerHeight="50"
|
:headerHeight="50"
|
||||||
|
:suppressHorizontalScroll="true"
|
||||||
:processCellForClipboard="processCellForClipboard"
|
:processCellForClipboard="processCellForClipboard"
|
||||||
:processCellFromClipboard="processCellFromClipboard"
|
:processCellFromClipboard="processCellFromClipboard"
|
||||||
:undoRedoCellEditing="true"
|
:undoRedoCellEditing="true"
|
||||||
:undoRedoCellEditingLimit="20"
|
:undoRedoCellEditingLimit="20"
|
||||||
|
@grid-ready="handleGridReady"
|
||||||
|
@grid-size-changed="handleGridSizeChanged"
|
||||||
|
@first-data-rendered="handleFirstDataRendered"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import type { ComponentPublicInstance } from 'vue'
|
import type { ComponentPublicInstance } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
|
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
|
||||||
@ -7,6 +7,9 @@ import localforage from 'localforage'
|
|||||||
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
|
import { myTheme ,gridOptions} from '@/lib/diyAgGridOptions'
|
||||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||||
import { addNumbers } from '@/lib/decimal'
|
import { addNumbers } from '@/lib/decimal'
|
||||||
|
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
|
import { getPricingMethodTotalsForService, getPricingMethodTotalsForServices } from '@/lib/pricingMethodTotals'
|
||||||
|
import { ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||||||
import { Search } from 'lucide-vue-next'
|
import { Search } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
DialogClose,
|
DialogClose,
|
||||||
@ -95,7 +98,7 @@ const updateGridCardHeight = () => {
|
|||||||
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
const paddingTop = style ? Number.parseFloat(style.paddingTop || '0') || 0 : 0
|
||||||
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
const paddingBottom = style ? Number.parseFloat(style.paddingBottom || '0') || 0 : 0
|
||||||
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
const nextHeight = Math.max(360, Math.floor(snapScrollHost.clientHeight - paddingTop - paddingBottom))
|
||||||
agGridHeight.value = nextHeight-20
|
agGridHeight.value = nextHeight-40
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -218,15 +221,23 @@ 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()
|
||||||
|
|
||||||
row.investScale = null
|
|
||||||
row.landScale = null
|
|
||||||
row.workload = null
|
|
||||||
row.hourly = null
|
|
||||||
row.subtotal = null
|
|
||||||
detailRows.value = [...detailRows.value]
|
|
||||||
|
|
||||||
await clearPricingPaneValues(row.id)
|
await clearPricingPaneValues(row.id)
|
||||||
|
const totals = await getPricingMethodTotalsForService({
|
||||||
|
contractId: props.contractId,
|
||||||
|
serviceId: row.id
|
||||||
|
})
|
||||||
|
detailRows.value = detailRows.value.map(item =>
|
||||||
|
item.id !== row.id
|
||||||
|
? item
|
||||||
|
: {
|
||||||
|
...item,
|
||||||
|
investScale: totals.investScale,
|
||||||
|
landScale: totals.landScale,
|
||||||
|
workload: totals.workload,
|
||||||
|
hourly: totals.hourly
|
||||||
|
}
|
||||||
|
)
|
||||||
|
await saveToIndexedDB()
|
||||||
}
|
}
|
||||||
|
|
||||||
const openEditTab = (row: DetailRow) => {
|
const openEditTab = (row: DetailRow) => {
|
||||||
@ -239,56 +250,66 @@ const openEditTab = (row: DetailRow) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const columnDefs: ColDef<DetailRow>[] = [
|
const columnDefs: ColDef<DetailRow>[] = [
|
||||||
{ headerName: '编码', field: 'code', minWidth: 80, flex: 1 },
|
{ headerName: '编码', field: 'code', minWidth: 50, maxWidth: 100 },
|
||||||
{ headerName: '名称', field: 'name', minWidth: 320, flex: 2 },
|
{ headerName: '名称', field: 'name', minWidth: 250, flex: 3, tooltipField: 'name' },
|
||||||
{
|
{
|
||||||
headerName: '投资规模法',
|
headerName: '投资规模法',
|
||||||
field: 'investScale',
|
field: 'investScale',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
editable: false,
|
editable: false,
|
||||||
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
valueParser: params => numericParser(params.newValue),
|
||||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '用地规模法',
|
headerName: '用地规模法',
|
||||||
field: 'landScale',
|
field: 'landScale',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 140,
|
minWidth: 140,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
editable: false,
|
editable: false,
|
||||||
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
valueParser: params => numericParser(params.newValue),
|
||||||
valueFormatter: params => (params.value == null ? '' : String(Number(params.value)))
|
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 2))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '工作量法',
|
headerName: '工作量法',
|
||||||
field: 'workload',
|
field: 'workload',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
editable: false,
|
editable: false,
|
||||||
|
|
||||||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||||||
valueParser: params => numericParser(params.newValue),
|
valueParser: params => numericParser(params.newValue),
|
||||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '工时法',
|
headerName: '工时法',
|
||||||
field: 'hourly',
|
field: 'hourly',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
flex: 2,
|
flex: 2,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
editable: false,
|
editable: false,
|
||||||
|
|
||||||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
||||||
valueParser: params => numericParser(params.newValue),
|
valueParser: params => numericParser(params.newValue),
|
||||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '小计',
|
headerName: '小计',
|
||||||
field: 'subtotal',
|
field: 'subtotal',
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
flex: 3,
|
flex: 3,
|
||||||
|
|
||||||
minWidth: 120,
|
minWidth: 120,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
editable: false,
|
editable: false,
|
||||||
valueGetter: params => {
|
valueGetter: params => {
|
||||||
if (!params.data) return null
|
if (!params.data) return null
|
||||||
@ -299,14 +320,14 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
valueOrZero(params.data.hourly)
|
valueOrZero(params.data.hourly)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
valueFormatter: params => (params.value == null ? '' : Number(params.value).toFixed(2))
|
valueFormatter: params => (params.value == null ? '' : formatThousands(params.value))
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
headerName: '操作',
|
headerName: '操作',
|
||||||
field: 'actions',
|
field: 'actions',
|
||||||
minWidth: 88,
|
minWidth: 50,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
maxWidth: 120,
|
||||||
editable: false,
|
editable: false,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filter: false,
|
filter: false,
|
||||||
@ -341,6 +362,29 @@ const detailGridOptions: GridOptions<DetailRow> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fillPricingTotalsForSelectedRows = async () => {
|
||||||
|
const serviceRows = detailRows.value.filter(row => !isFixedRow(row))
|
||||||
|
if (serviceRows.length === 0) return
|
||||||
|
|
||||||
|
const totalsByServiceId = await getPricingMethodTotalsForServices({
|
||||||
|
contractId: props.contractId,
|
||||||
|
serviceIds: serviceRows.map(row => row.id)
|
||||||
|
})
|
||||||
|
|
||||||
|
detailRows.value = detailRows.value.map(row => {
|
||||||
|
if (isFixedRow(row)) return row
|
||||||
|
const totals = totalsByServiceId.get(String(row.id))
|
||||||
|
if (!totals) return row
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
investScale: totals.investScale,
|
||||||
|
landScale: totals.landScale,
|
||||||
|
workload: totals.workload,
|
||||||
|
hourly: totals.hourly
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const applySelection = (codes: string[]) => {
|
const applySelection = (codes: string[]) => {
|
||||||
const prevSelectedSet = new Set(selectedIds.value)
|
const prevSelectedSet = new Set(selectedIds.value)
|
||||||
const uniqueIds = Array.from(new Set(codes)).filter(
|
const uniqueIds = Array.from(new Set(codes)).filter(
|
||||||
@ -410,9 +454,15 @@ const handlePickerOpenChange = (open: boolean) => {
|
|||||||
pickerOpen.value = open
|
pickerOpen.value = open
|
||||||
}
|
}
|
||||||
|
|
||||||
const confirmPicker = () => {
|
const confirmPicker = async () => {
|
||||||
applySelection(pickerTempIds.value)
|
applySelection(pickerTempIds.value)
|
||||||
void saveToIndexedDB()
|
try {
|
||||||
|
await fillPricingTotalsForSelectedRows()
|
||||||
|
await saveToIndexedDB()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('confirmPicker failed:', error)
|
||||||
|
await saveToIndexedDB()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const clearPickerSelection = () => {
|
const clearPickerSelection = () => {
|
||||||
@ -556,6 +606,12 @@ const loadFromIndexedDB = async () => {
|
|||||||
hourly: typeof old.hourly === 'number' ? old.hourly : null
|
hourly: typeof old.hourly === 'number' ? old.hourly : null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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 = []
|
||||||
@ -563,6 +619,14 @@ const loadFromIndexedDB = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => pricingPaneReloadStore.getReloadVersion(props.contractId, ZXFW_RELOAD_SERVICE_KEY),
|
||||||
|
(nextVersion, prevVersion) => {
|
||||||
|
if (nextVersion === prevVersion || nextVersion === 0) return
|
||||||
|
void loadFromIndexedDB()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
const handleCellValueChanged = () => {
|
const handleCellValueChanged = () => {
|
||||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||||
@ -692,47 +756,3 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
|
||||||
.ag-floating-top {
|
|
||||||
overflow-y: auto !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zxfw-action-wrap {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 6px;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.zxfw-action-btn {
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-size: 14px;
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item-clickable {
|
|
||||||
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item-selected {
|
|
||||||
transform: translateY(-1px);
|
|
||||||
border-color: rgba(14, 165, 233, 0.75);
|
|
||||||
box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.3);
|
|
||||||
background: rgba(14, 165, 233, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item-selected-drag {
|
|
||||||
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.35), 0 0 0 1px rgba(14, 165, 233, 0.25);
|
|
||||||
}
|
|
||||||
|
|
||||||
.picker-item-clickable:not(.picker-item-dragging):active {
|
|
||||||
transform: translateY(1px) scale(0.985);
|
|
||||||
border-color: rgba(14, 165, 233, 0.8);
|
|
||||||
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.22);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
AlertDialogTrigger,
|
AlertDialogTrigger,
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
|
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||||||
|
|
||||||
interface DataEntry {
|
interface DataEntry {
|
||||||
key: string
|
key: string
|
||||||
@ -30,7 +31,10 @@ interface DataPackage {
|
|||||||
localStorage: DataEntry[]
|
localStorage: DataEntry[]
|
||||||
sessionStorage: DataEntry[]
|
sessionStorage: DataEntry[]
|
||||||
localforageDefault: DataEntry[]
|
localforageDefault: DataEntry[]
|
||||||
localforageFormState: DataEntry[]
|
}
|
||||||
|
|
||||||
|
type XmInfoLike = {
|
||||||
|
projectName?: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
const componentMap: Record<string, any> = {
|
const componentMap: Record<string, any> = {
|
||||||
@ -41,10 +45,7 @@ const componentMap: Record<string, any> = {
|
|||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
|
|
||||||
const formStore = localforage.createInstance({
|
|
||||||
name: 'jgjs-pricing-db',
|
|
||||||
storeName: 'form_state'
|
|
||||||
})
|
|
||||||
|
|
||||||
const tabContextOpen = ref(false)
|
const tabContextOpen = ref(false)
|
||||||
const tabContextX = ref(0)
|
const tabContextX = ref(0)
|
||||||
@ -242,23 +243,57 @@ const writeForage = async (store: typeof localforage, entries: DataEntry[]) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const normalizeEntries = (value: unknown): DataEntry[] => {
|
||||||
|
if (!Array.isArray(value)) return []
|
||||||
|
return value
|
||||||
|
.filter(item => item && typeof item === 'object' && typeof (item as any).key === 'string')
|
||||||
|
.map(item => ({ key: String((item as any).key), value: (item as any).value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
const sanitizeFileNamePart = (value: string): string => {
|
||||||
|
const cleaned = value
|
||||||
|
.replace(/[\\/:*?"<>|]/g, '_')
|
||||||
|
.replace(/\s+/g, ' ')
|
||||||
|
.trim()
|
||||||
|
return cleaned || '造价项目'
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatExportTimestamp = (date: Date): string => {
|
||||||
|
const yyyy = date.getFullYear()
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(date.getHours()).padStart(2, '0')
|
||||||
|
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||||
|
return `${yyyy}${mm}${dd}-${hh}${mi}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExportProjectName = (entries: DataEntry[]): string => {
|
||||||
|
const target = entries.find(item => item.key === 'xm-info-v3')
|
||||||
|
const data = (target?.value || {}) as XmInfoLike
|
||||||
|
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
|
||||||
|
}
|
||||||
|
|
||||||
const exportData = async () => {
|
const exportData = async () => {
|
||||||
try {
|
try {
|
||||||
|
const now = new Date()
|
||||||
const payload: DataPackage = {
|
const payload: DataPackage = {
|
||||||
version: 1,
|
version: 1,
|
||||||
exportedAt: new Date().toISOString(),
|
exportedAt: now.toISOString(),
|
||||||
localStorage: readWebStorage(localStorage),
|
localStorage: readWebStorage(localStorage),
|
||||||
sessionStorage: readWebStorage(sessionStorage),
|
sessionStorage: readWebStorage(sessionStorage),
|
||||||
localforageDefault: await readForage(localforage),
|
localforageDefault: await readForage(localforage),
|
||||||
localforageFormState: await readForage(formStore as any)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const content = JSON.stringify(payload, null, 2)
|
const content = await encodeZwArchive(payload)
|
||||||
const blob = new Blob([content], { type: 'application/json;charset=utf-8' })
|
const binary = new Uint8Array(content.length)
|
||||||
|
binary.set(content)
|
||||||
|
const blob = new Blob([binary], { type: 'application/octet-stream' })
|
||||||
const url = URL.createObjectURL(blob)
|
const url = URL.createObjectURL(blob)
|
||||||
const link = document.createElement('a')
|
const link = document.createElement('a')
|
||||||
link.href = url
|
link.href = url
|
||||||
link.download = `造价项目-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.json`
|
const projectName = getExportProjectName(payload.localforageDefault)
|
||||||
|
const timestamp = formatExportTimestamp(now)
|
||||||
|
link.download = `${projectName}-${timestamp}${ZW_FILE_EXTENSION}`
|
||||||
document.body.appendChild(link)
|
document.body.appendChild(link)
|
||||||
link.click()
|
link.click()
|
||||||
document.body.removeChild(link)
|
document.body.removeChild(link)
|
||||||
@ -281,20 +316,22 @@ const importData = async (event: Event) => {
|
|||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const text = await file.text()
|
if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) {
|
||||||
const payload = JSON.parse(text) as DataPackage
|
throw new Error('INVALID_FILE_EXT')
|
||||||
|
}
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const payload = await decodeZwArchive<DataPackage>(buffer)
|
||||||
|
|
||||||
writeWebStorage(localStorage, payload.localStorage || [])
|
writeWebStorage(localStorage, normalizeEntries(payload.localStorage))
|
||||||
writeWebStorage(sessionStorage, payload.sessionStorage || [])
|
writeWebStorage(sessionStorage, normalizeEntries(payload.sessionStorage))
|
||||||
await writeForage(localforage, payload.localforageDefault || [])
|
await writeForage(localforage, normalizeEntries(payload.localforageDefault))
|
||||||
await writeForage(formStore as any, payload.localforageFormState || [])
|
|
||||||
|
|
||||||
tabStore.resetTabs()
|
tabStore.resetTabs()
|
||||||
dataMenuOpen.value = false
|
dataMenuOpen.value = false
|
||||||
window.location.reload()
|
window.location.reload()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('import failed:', error)
|
console.error('import failed:', error)
|
||||||
window.alert('导入失败,文件格式不正确。')
|
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||||
} finally {
|
} finally {
|
||||||
input.value = ''
|
input.value = ''
|
||||||
}
|
}
|
||||||
@ -397,31 +434,31 @@ watch(
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|
||||||
<div ref="dataMenuRef" class="relative mb-2 shrink-0">
|
<div ref="dataMenuRef" class="relative mb-2 shrink-0">
|
||||||
<Button variant="outline" size="sm" @click="dataMenuOpen = !dataMenuOpen">
|
<Button variant="outline" size="sm" class="cursor-pointer" @click="dataMenuOpen = !dataMenuOpen">
|
||||||
<ChevronDown class="h-4 w-4 mr-1" />
|
<ChevronDown class="h-4 w-4 mr-1" />
|
||||||
导入/导出
|
导入/导出
|
||||||
</Button>
|
</Button>
|
||||||
<div
|
<div
|
||||||
v-if="dataMenuOpen"
|
v-if="dataMenuOpen"
|
||||||
class="absolute right-0 top-full mt-1 z-50 min-w-[140px] rounded-md border bg-background p-1 shadow-md"
|
class="absolute right-0 top-full mt-1 z-50 min-w-[108px] rounded-md border bg-background p-1 shadow-md"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
@click="exportData"
|
@click="exportData"
|
||||||
>
|
>
|
||||||
导出数据
|
导出
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="w-full rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
class="w-full cursor-pointer rounded px-3 py-1.5 text-left text-sm hover:bg-muted"
|
||||||
@click="triggerImport"
|
@click="triggerImport"
|
||||||
>
|
>
|
||||||
导入数据
|
导入
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
ref="importFileRef"
|
ref="importFileRef"
|
||||||
type="file"
|
type="file"
|
||||||
accept="application/json,.json"
|
accept=".zw"
|
||||||
class="hidden"
|
class="hidden"
|
||||||
@change="importData"
|
@change="importData"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -5,8 +5,8 @@
|
|||||||
} from "ag-grid-community"
|
} from "ag-grid-community"
|
||||||
const borderConfig = {
|
const borderConfig = {
|
||||||
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
|
style: "solid", // 虚线改实线更简洁,也可保留 dotted 但建议用 solid
|
||||||
width: 0.5, // 更细的边框,减少视觉干扰
|
width: 0.3, // 更细的边框,减少视觉干扰
|
||||||
color: "#e5e7eb" // 浅灰色边框,清新不刺眼
|
color: "#d3d3d3" // 浅灰色边框,清新不刺眼
|
||||||
};
|
};
|
||||||
|
|
||||||
// 简洁清新风格的主题配置
|
// 简洁清新风格的主题配置
|
||||||
@ -32,10 +32,15 @@ export const myTheme = themeQuartz.withParams({
|
|||||||
export const gridOptions: GridOptions<any> = {
|
export const gridOptions: GridOptions<any> = {
|
||||||
treeData: true,
|
treeData: true,
|
||||||
animateRows: true,
|
animateRows: true,
|
||||||
|
tooltipShowMode: 'whenTruncated',
|
||||||
suppressAggFuncInHeader: true,
|
suppressAggFuncInHeader: true,
|
||||||
singleClickEdit: true,
|
singleClickEdit: true,
|
||||||
suppressClickEdit: false,
|
suppressClickEdit: false,
|
||||||
suppressContextMenu: false,
|
suppressContextMenu: false,
|
||||||
|
autoSizeStrategy: {
|
||||||
|
type: 'fitGridWidth',
|
||||||
|
defaultMinWidth: 100,
|
||||||
|
},
|
||||||
groupDefaultExpanded: -1,
|
groupDefaultExpanded: -1,
|
||||||
suppressFieldDotNotation: true,
|
suppressFieldDotNotation: true,
|
||||||
getDataPath: data => data.path,
|
getDataPath: data => data.path,
|
||||||
@ -43,6 +48,12 @@ export const gridOptions: GridOptions<any> = {
|
|||||||
defaultColDef: {
|
defaultColDef: {
|
||||||
resizable: true,
|
resizable: true,
|
||||||
sortable: false,
|
sortable: false,
|
||||||
filter: false
|
filter: false,
|
||||||
|
wrapHeaderText: true,
|
||||||
|
autoHeaderHeight: true
|
||||||
|
},
|
||||||
|
defaultColGroupDef: {
|
||||||
|
wrapHeaderText: true,
|
||||||
|
autoHeaderHeight: true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19
src/lib/numberFormat.ts
Normal file
19
src/lib/numberFormat.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export const formatThousands = (value: unknown, fractionDigits = 2) => {
|
||||||
|
if (value === '' || value == null) return ''
|
||||||
|
const numericValue = Number(value)
|
||||||
|
if (!Number.isFinite(numericValue)) return ''
|
||||||
|
return numericValue.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: fractionDigits,
|
||||||
|
maximumFractionDigits: fractionDigits
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatThousandsFlexible = (value: unknown, maxFractionDigits = 20) => {
|
||||||
|
if (value === '' || value == null) return ''
|
||||||
|
const numericValue = Number(value)
|
||||||
|
if (!Number.isFinite(numericValue)) return ''
|
||||||
|
return numericValue.toLocaleString('zh-CN', {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: maxFractionDigits
|
||||||
|
})
|
||||||
|
}
|
||||||
317
src/lib/pricingMethodTotals.ts
Normal file
317
src/lib/pricingMethodTotals.ts
Normal file
@ -0,0 +1,317 @@
|
|||||||
|
import localforage from 'localforage'
|
||||||
|
import { expertList, getBasicFeeFromScale, majorList, serviceList, taskList } from '@/sql'
|
||||||
|
import { addNumbers, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||||
|
|
||||||
|
interface StoredDetailRowsState<T = any> {
|
||||||
|
detailRows?: T[]
|
||||||
|
}
|
||||||
|
|
||||||
|
type MaybeNumber = number | null | undefined
|
||||||
|
|
||||||
|
interface ScaleRow {
|
||||||
|
id: string
|
||||||
|
amount: number | null
|
||||||
|
landArea: number | null
|
||||||
|
consultCategoryFactor: number | null
|
||||||
|
majorFactor: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface WorkloadRow {
|
||||||
|
id: string
|
||||||
|
conversion: number | null
|
||||||
|
workload: number | null
|
||||||
|
budgetAdoptedUnitPrice: number | null
|
||||||
|
consultCategoryFactor: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HourlyRow {
|
||||||
|
id: string
|
||||||
|
adoptedBudgetUnitPrice: number | null
|
||||||
|
personnelCount: number | null
|
||||||
|
workdayCount: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MajorLite {
|
||||||
|
code: string
|
||||||
|
defCoe: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ServiceLite {
|
||||||
|
defCoe: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskLite {
|
||||||
|
serviceID: number
|
||||||
|
conversion: number | null
|
||||||
|
defPrice: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExpertLite {
|
||||||
|
defPrice: number | null
|
||||||
|
manageCoe: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PricingMethodTotals {
|
||||||
|
investScale: number | null
|
||||||
|
landScale: number | null
|
||||||
|
workload: number | null
|
||||||
|
hourly: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const toFiniteNumberOrNull = (value: unknown): number | null =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
|
||||||
|
const hasOwn = (obj: unknown, key: string) =>
|
||||||
|
Object.prototype.hasOwnProperty.call(obj || {}, key)
|
||||||
|
|
||||||
|
const getDefaultConsultCategoryFactor = (serviceId: string | number) => {
|
||||||
|
const service = (serviceList as Record<string, ServiceLite | undefined>)[String(serviceId)]
|
||||||
|
return toFiniteNumberOrNull(service?.defCoe)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDefaultMajorFactorById = (id: string) => {
|
||||||
|
const major = (majorList as Record<string, MajorLite | undefined>)[id]
|
||||||
|
return toFiniteNumberOrNull(major?.defCoe)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getMajorLeafIds = () =>
|
||||||
|
Object.entries(majorList as Record<string, MajorLite>)
|
||||||
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
.filter(([, item]) => Boolean(item?.code && item.code.includes('-')))
|
||||||
|
.map(([id]) => id)
|
||||||
|
|
||||||
|
const buildDefaultScaleRows = (serviceId: string | number): ScaleRow[] => {
|
||||||
|
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
||||||
|
return getMajorLeafIds().map(id => ({
|
||||||
|
id,
|
||||||
|
amount: null,
|
||||||
|
landArea: null,
|
||||||
|
consultCategoryFactor: defaultConsultCategoryFactor,
|
||||||
|
majorFactor: getDefaultMajorFactorById(id)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeScaleRows = (
|
||||||
|
serviceId: string | number,
|
||||||
|
rowsFromDb: Array<Partial<ScaleRow> & Pick<ScaleRow, 'id'>> | undefined
|
||||||
|
): ScaleRow[] => {
|
||||||
|
const dbValueMap = new Map<string, Partial<ScaleRow> & Pick<ScaleRow, 'id'>>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(String(row.id), row)
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
||||||
|
return buildDefaultScaleRows(serviceId).map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
const hasConsultCategoryFactor = hasOwn(fromDb, 'consultCategoryFactor')
|
||||||
|
const hasMajorFactor = hasOwn(fromDb, 'majorFactor')
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
amount: toFiniteNumberOrNull(fromDb.amount),
|
||||||
|
landArea: toFiniteNumberOrNull(fromDb.landArea),
|
||||||
|
consultCategoryFactor:
|
||||||
|
toFiniteNumberOrNull(fromDb.consultCategoryFactor) ??
|
||||||
|
(hasConsultCategoryFactor ? null : defaultConsultCategoryFactor),
|
||||||
|
majorFactor:
|
||||||
|
toFiniteNumberOrNull(fromDb.majorFactor) ??
|
||||||
|
(hasMajorFactor ? null : getDefaultMajorFactorById(row.id))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) => {
|
||||||
|
const result = getBasicFeeFromScale(amount, 'cost')
|
||||||
|
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) => {
|
||||||
|
const result = getBasicFeeFromScale(landArea, 'area')
|
||||||
|
return result ? roundTo(addNumbers(result.basic, result.optional), 2) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getInvestmentBudgetFee = (row: ScaleRow) => {
|
||||||
|
const benchmarkBudget = getBenchmarkBudgetByAmount(row.amount)
|
||||||
|
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
|
||||||
|
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getLandBudgetFee = (row: ScaleRow) => {
|
||||||
|
const benchmarkBudget = getBenchmarkBudgetByLandArea(row.landArea)
|
||||||
|
if (benchmarkBudget == null || row.majorFactor == null || row.consultCategoryFactor == null) return null
|
||||||
|
return roundTo(toDecimal(benchmarkBudget).mul(row.majorFactor).mul(row.consultCategoryFactor), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTaskEntriesByServiceId = (serviceId: string | number) =>
|
||||||
|
Object.entries(taskList as Record<string, TaskLite>)
|
||||||
|
.sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
.filter(([, task]) => Number(task.serviceID) === Number(serviceId))
|
||||||
|
|
||||||
|
const buildDefaultWorkloadRows = (serviceId: string | number): WorkloadRow[] => {
|
||||||
|
const defaultConsultCategoryFactor = getDefaultConsultCategoryFactor(serviceId)
|
||||||
|
return getTaskEntriesByServiceId(serviceId).map(([taskId, task], order) => ({
|
||||||
|
id: `task-${taskId}-${order}`,
|
||||||
|
conversion: toFiniteNumberOrNull(task.conversion),
|
||||||
|
workload: null,
|
||||||
|
budgetAdoptedUnitPrice: toFiniteNumberOrNull(task.defPrice),
|
||||||
|
consultCategoryFactor: defaultConsultCategoryFactor
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const mergeWorkloadRows = (
|
||||||
|
serviceId: string | number,
|
||||||
|
rowsFromDb: Array<Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>> | undefined
|
||||||
|
): WorkloadRow[] => {
|
||||||
|
const dbValueMap = new Map<string, Partial<WorkloadRow> & Pick<WorkloadRow, 'id'>>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(String(row.id), row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultWorkloadRows(serviceId).map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
workload: toFiniteNumberOrNull(fromDb.workload),
|
||||||
|
budgetAdoptedUnitPrice: toFiniteNumberOrNull(fromDb.budgetAdoptedUnitPrice),
|
||||||
|
consultCategoryFactor: toFiniteNumberOrNull(fromDb.consultCategoryFactor)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcWorkloadServiceFee = (row: WorkloadRow) => {
|
||||||
|
if (
|
||||||
|
row.budgetAdoptedUnitPrice == null ||
|
||||||
|
row.conversion == null ||
|
||||||
|
row.workload == null ||
|
||||||
|
row.consultCategoryFactor == null
|
||||||
|
) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return roundTo(
|
||||||
|
toDecimal(row.budgetAdoptedUnitPrice).mul(row.conversion).mul(row.workload).mul(row.consultCategoryFactor),
|
||||||
|
2
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getExpertEntries = () =>
|
||||||
|
Object.entries(expertList as Record<string, ExpertLite>).sort((a, b) => Number(a[0]) - Number(b[0]))
|
||||||
|
|
||||||
|
const getDefaultHourlyAdoptedPrice = (expert: ExpertLite) => {
|
||||||
|
if (expert.defPrice == null || expert.manageCoe == null) return null
|
||||||
|
return roundTo(toDecimal(expert.defPrice).mul(expert.manageCoe), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildDefaultHourlyRows = (): HourlyRow[] =>
|
||||||
|
getExpertEntries().map(([expertId, expert]) => ({
|
||||||
|
id: `expert-${expertId}`,
|
||||||
|
adoptedBudgetUnitPrice: getDefaultHourlyAdoptedPrice(expert),
|
||||||
|
personnelCount: null,
|
||||||
|
workdayCount: null
|
||||||
|
}))
|
||||||
|
|
||||||
|
const mergeHourlyRows = (
|
||||||
|
rowsFromDb: Array<Partial<HourlyRow> & Pick<HourlyRow, 'id'>> | undefined
|
||||||
|
): HourlyRow[] => {
|
||||||
|
const dbValueMap = new Map<string, Partial<HourlyRow> & Pick<HourlyRow, 'id'>>()
|
||||||
|
for (const row of rowsFromDb || []) {
|
||||||
|
dbValueMap.set(String(row.id), row)
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDefaultHourlyRows().map(row => {
|
||||||
|
const fromDb = dbValueMap.get(row.id)
|
||||||
|
if (!fromDb) return row
|
||||||
|
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
adoptedBudgetUnitPrice: toFiniteNumberOrNull(fromDb.adoptedBudgetUnitPrice),
|
||||||
|
personnelCount: toFiniteNumberOrNull(fromDb.personnelCount),
|
||||||
|
workdayCount: toFiniteNumberOrNull(fromDb.workdayCount)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const calcHourlyServiceBudget = (row: HourlyRow) => {
|
||||||
|
if (row.adoptedBudgetUnitPrice == null || row.personnelCount == null || row.workdayCount == null) return null
|
||||||
|
return roundTo(toDecimal(row.adoptedBudgetUnitPrice).mul(row.personnelCount).mul(row.workdayCount), 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPricingMethodTotalsForService = async (params: {
|
||||||
|
contractId: string
|
||||||
|
serviceId: string | number
|
||||||
|
}): Promise<PricingMethodTotals> => {
|
||||||
|
const serviceId = String(params.serviceId)
|
||||||
|
const htDbKey = `ht-info-v3-${params.contractId}`
|
||||||
|
const investDbKey = `tzGMF-${params.contractId}-${serviceId}`
|
||||||
|
const landDbKey = `ydGMF-${params.contractId}-${serviceId}`
|
||||||
|
const workloadDbKey = `gzlF-${params.contractId}-${serviceId}`
|
||||||
|
const hourlyDbKey = `hourlyPricing-${params.contractId}-${serviceId}`
|
||||||
|
|
||||||
|
const [investData, landData, workloadData, hourlyData, htData] = await Promise.all([
|
||||||
|
localforage.getItem<StoredDetailRowsState>(investDbKey),
|
||||||
|
localforage.getItem<StoredDetailRowsState>(landDbKey),
|
||||||
|
localforage.getItem<StoredDetailRowsState>(workloadDbKey),
|
||||||
|
localforage.getItem<StoredDetailRowsState>(hourlyDbKey),
|
||||||
|
localforage.getItem<StoredDetailRowsState>(htDbKey)
|
||||||
|
])
|
||||||
|
|
||||||
|
const investRows =
|
||||||
|
investData?.detailRows != null
|
||||||
|
? mergeScaleRows(serviceId, investData.detailRows as any)
|
||||||
|
: htData?.detailRows != null
|
||||||
|
? mergeScaleRows(serviceId, htData.detailRows as any)
|
||||||
|
: buildDefaultScaleRows(serviceId)
|
||||||
|
const investScale = sumByNumber(investRows, row => getInvestmentBudgetFee(row))
|
||||||
|
|
||||||
|
const landRows =
|
||||||
|
landData?.detailRows != null
|
||||||
|
? mergeScaleRows(serviceId, landData.detailRows as any)
|
||||||
|
: htData?.detailRows != null
|
||||||
|
? mergeScaleRows(serviceId, htData.detailRows as any)
|
||||||
|
: buildDefaultScaleRows(serviceId)
|
||||||
|
const landScale = sumByNumber(landRows, row => getLandBudgetFee(row))
|
||||||
|
|
||||||
|
const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId)
|
||||||
|
const workload =
|
||||||
|
defaultWorkloadRows.length === 0
|
||||||
|
? null
|
||||||
|
: sumByNumber(
|
||||||
|
workloadData?.detailRows != null
|
||||||
|
? mergeWorkloadRows(serviceId, workloadData.detailRows as any)
|
||||||
|
: defaultWorkloadRows,
|
||||||
|
row => calcWorkloadServiceFee(row)
|
||||||
|
)
|
||||||
|
|
||||||
|
const hourlyRows =
|
||||||
|
hourlyData?.detailRows != null
|
||||||
|
? mergeHourlyRows(hourlyData.detailRows as any)
|
||||||
|
: buildDefaultHourlyRows()
|
||||||
|
const hourly = sumByNumber(hourlyRows, row => calcHourlyServiceBudget(row))
|
||||||
|
|
||||||
|
return {
|
||||||
|
investScale,
|
||||||
|
landScale,
|
||||||
|
workload,
|
||||||
|
hourly
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getPricingMethodTotalsForServices = async (params: {
|
||||||
|
contractId: string
|
||||||
|
serviceIds: Array<string | number>
|
||||||
|
}) => {
|
||||||
|
const result = new Map<string, PricingMethodTotals>()
|
||||||
|
await Promise.all(
|
||||||
|
params.serviceIds.map(async serviceId => {
|
||||||
|
const totals = await getPricingMethodTotalsForService({
|
||||||
|
contractId: params.contractId,
|
||||||
|
serviceId
|
||||||
|
})
|
||||||
|
result.set(String(serviceId), totals)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
}
|
||||||
39
src/lib/xmFactorDefaults.ts
Normal file
39
src/lib/xmFactorDefaults.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import localforage from 'localforage'
|
||||||
|
|
||||||
|
const CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
|
||||||
|
const MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
|
||||||
|
|
||||||
|
type XmFactorRow = {
|
||||||
|
id: string
|
||||||
|
standardFactor: number | null
|
||||||
|
budgetValue: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
type XmFactorState = {
|
||||||
|
detailRows?: XmFactorRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const toFiniteNumberOrNull = (value: unknown): number | null =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
|
||||||
|
const resolveFactorValue = (row: Partial<XmFactorRow> | undefined): number | null => {
|
||||||
|
if (!row) return null
|
||||||
|
const budgetValue = toFiniteNumberOrNull(row.budgetValue)
|
||||||
|
if (budgetValue != null) return budgetValue
|
||||||
|
return toFiniteNumberOrNull(row.standardFactor)
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadFactorMap = async (storageKey: string): Promise<Map<string, number | null>> => {
|
||||||
|
const data = await localforage.getItem<XmFactorState>(storageKey)
|
||||||
|
const map = new Map<string, number | null>()
|
||||||
|
for (const row of data?.detailRows || []) {
|
||||||
|
if (!row?.id) continue
|
||||||
|
map.set(String(row.id), resolveFactorValue(row))
|
||||||
|
}
|
||||||
|
return map
|
||||||
|
}
|
||||||
|
|
||||||
|
export const loadConsultCategoryFactorMap = async () => loadFactorMap(CONSULT_CATEGORY_FACTOR_KEY)
|
||||||
|
|
||||||
|
export const loadMajorFactorMap = async () => loadFactorMap(MAJOR_FACTOR_KEY)
|
||||||
|
|
||||||
99
src/lib/zwArchive.ts
Normal file
99
src/lib/zwArchive.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
const ZW_VERSION = 1
|
||||||
|
const KEY_SEED = 'JGJS2026::ZW::ARCHIVE::V1::DO_NOT_TAMPER'
|
||||||
|
const MAGIC_BYTES = new Uint8Array([0x4a, 0x47, 0x4a, 0x53, 0x5a, 0x57]) // JGJSZW
|
||||||
|
|
||||||
|
export const ZW_FILE_EXTENSION = '.zw'
|
||||||
|
|
||||||
|
const encoder = new TextEncoder()
|
||||||
|
const decoder = new TextDecoder()
|
||||||
|
let cachedKeyPromise: Promise<CryptoKey> | null = null
|
||||||
|
|
||||||
|
const toArrayBuffer = (bytes: Uint8Array): ArrayBuffer =>
|
||||||
|
bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||||
|
|
||||||
|
const getArchiveKey = async (): Promise<CryptoKey> => {
|
||||||
|
if (!cachedKeyPromise) {
|
||||||
|
cachedKeyPromise = (async () => {
|
||||||
|
const hash = await crypto.subtle.digest('SHA-256', encoder.encode(KEY_SEED))
|
||||||
|
return crypto.subtle.importKey('raw', hash, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt'])
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
return cachedKeyPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export const encodeZwArchive = async (payload: unknown): Promise<Uint8Array> => {
|
||||||
|
const key = await getArchiveKey()
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||||
|
const ivBuffer = toArrayBuffer(iv)
|
||||||
|
const plainText = encoder.encode(JSON.stringify(payload))
|
||||||
|
const cipherBuffer = await crypto.subtle.encrypt(
|
||||||
|
{ name: 'AES-GCM', iv: ivBuffer },
|
||||||
|
key,
|
||||||
|
toArrayBuffer(plainText)
|
||||||
|
)
|
||||||
|
const cipher = new Uint8Array(cipherBuffer)
|
||||||
|
const result = new Uint8Array(MAGIC_BYTES.length + 1 + 1 + iv.length + cipher.length)
|
||||||
|
let offset = 0
|
||||||
|
result.set(MAGIC_BYTES, offset)
|
||||||
|
offset += MAGIC_BYTES.length
|
||||||
|
result[offset] = ZW_VERSION
|
||||||
|
offset += 1
|
||||||
|
result[offset] = iv.length
|
||||||
|
offset += 1
|
||||||
|
result.set(iv, offset)
|
||||||
|
offset += iv.length
|
||||||
|
result.set(cipher, offset)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeZwArchive = async <T>(raw: ArrayBuffer | Uint8Array): Promise<T> => {
|
||||||
|
const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw)
|
||||||
|
const minLength = MAGIC_BYTES.length + 1 + 1 + 1 + 16
|
||||||
|
if (bytes.length < minLength) {
|
||||||
|
throw new Error('INVALID_ZW_PAYLOAD')
|
||||||
|
}
|
||||||
|
|
||||||
|
let offset = 0
|
||||||
|
for (let i = 0; i < MAGIC_BYTES.length; i += 1) {
|
||||||
|
if (bytes[offset + i] !== MAGIC_BYTES[i]) {
|
||||||
|
throw new Error('INVALID_ZW_HEADER')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
offset += MAGIC_BYTES.length
|
||||||
|
|
||||||
|
const version = bytes[offset]
|
||||||
|
offset += 1
|
||||||
|
if (version !== ZW_VERSION) {
|
||||||
|
throw new Error('INVALID_ZW_VERSION')
|
||||||
|
}
|
||||||
|
|
||||||
|
const ivLength = bytes[offset]
|
||||||
|
offset += 1
|
||||||
|
if (ivLength <= 0 || bytes.length <= offset + ivLength) {
|
||||||
|
throw new Error('INVALID_ZW_PAYLOAD')
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = bytes.slice(offset, offset + ivLength)
|
||||||
|
offset += ivLength
|
||||||
|
const cipher = bytes.slice(offset)
|
||||||
|
if (cipher.length < 16) {
|
||||||
|
throw new Error('INVALID_ZW_PAYLOAD')
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = await getArchiveKey()
|
||||||
|
const ivBuffer = toArrayBuffer(iv)
|
||||||
|
const cipherBuffer = toArrayBuffer(cipher)
|
||||||
|
|
||||||
|
let plainBuffer: ArrayBuffer
|
||||||
|
try {
|
||||||
|
plainBuffer = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: ivBuffer }, key, cipherBuffer)
|
||||||
|
} catch {
|
||||||
|
throw new Error('INVALID_ZW_TAMPERED')
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return JSON.parse(decoder.decode(plainBuffer)) as T
|
||||||
|
} catch {
|
||||||
|
throw new Error('INVALID_ZW_CONTENT')
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/lib/zxFwPricingSync.ts
Normal file
56
src/lib/zxFwPricingSync.ts
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import localforage from 'localforage'
|
||||||
|
|
||||||
|
export type ZxFwPricingField = 'investScale' | 'landScale' | 'workload' | 'hourly'
|
||||||
|
|
||||||
|
interface ZxFwDetailRow {
|
||||||
|
id: string
|
||||||
|
investScale: number | null
|
||||||
|
landScale: number | null
|
||||||
|
workload: number | null
|
||||||
|
hourly: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ZxFwState {
|
||||||
|
selectedIds?: string[]
|
||||||
|
selectedCodes?: string[]
|
||||||
|
detailRows: ZxFwDetailRow[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZXFW_RELOAD_SERVICE_KEY = 'zxfw-main'
|
||||||
|
|
||||||
|
const toFiniteNumberOrNull = (value: number | null | undefined) =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||||
|
|
||||||
|
export const syncPricingTotalToZxFw = async (params: {
|
||||||
|
contractId: string
|
||||||
|
serviceId: string | number
|
||||||
|
field: ZxFwPricingField
|
||||||
|
value: number | null | undefined
|
||||||
|
}) => {
|
||||||
|
const dbKey = `zxFW-${params.contractId}`
|
||||||
|
const data = await localforage.getItem<ZxFwState>(dbKey)
|
||||||
|
if (!data?.detailRows?.length) return false
|
||||||
|
|
||||||
|
const targetServiceId = String(params.serviceId)
|
||||||
|
const nextValue = toFiniteNumberOrNull(params.value)
|
||||||
|
let changed = false
|
||||||
|
|
||||||
|
const nextRows = data.detailRows.map(row => {
|
||||||
|
if (String(row.id) !== targetServiceId) return row
|
||||||
|
const currentValue = toFiniteNumberOrNull(row[params.field])
|
||||||
|
if (currentValue === nextValue) return row
|
||||||
|
changed = true
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
[params.field]: nextValue
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!changed) return false
|
||||||
|
|
||||||
|
await localforage.setItem(dbKey, {
|
||||||
|
...data,
|
||||||
|
detailRows: nextRows
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
@ -62,8 +62,8 @@ export const serviceList = {
|
|||||||
24: { code: 'D4-10', name: '工程变更费用咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '' },
|
24: { code: 'D4-10', name: '工程变更费用咨询', maxCoe: null, minCoe: null, defCoe: 0.5, desc: '' },
|
||||||
25: { code: 'D4-11', name: '调整估算', maxCoe: 0.2, minCoe: 0.1, defCoe: 0.15, desc: '' },
|
25: { code: 'D4-11', name: '调整估算', maxCoe: 0.2, minCoe: 0.1, defCoe: 0.15, desc: '' },
|
||||||
26: { code: 'D4-12', name: '调整概算', maxCoe: 0.3, minCoe: 0.15, defCoe: 0.225, desc: '本表系数适用于采用规模计价法基准预算的系数;依据其调整时期所在建设阶段和基础资料的不同,其系数取值不同。' },
|
26: { code: 'D4-12', name: '调整概算', maxCoe: 0.3, minCoe: 0.15, defCoe: 0.225, desc: '本表系数适用于采用规模计价法基准预算的系数;依据其调整时期所在建设阶段和基础资料的不同,其系数取值不同。' },
|
||||||
27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。' },
|
27: { code: 'D4-13', name: '造价检查', maxCoe: null, minCoe: null, defCoe: null, desc: '可按照服务工日数量×服务工日人工单价×综合预算系数;也可按照服务工日数量×服务工日综合预算单价。' ,notshowByzxflxs:true},
|
||||||
28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。' },
|
28: { code: 'D4-14', name: '其他专项咨询', maxCoe: null, minCoe: null, defCoe: null, desc: '可参照相同或相似服务的系数。' ,notshowByzxflxs:true},
|
||||||
};
|
};
|
||||||
//basicParam预算基数
|
//basicParam预算基数
|
||||||
|
|
||||||
@ -105,7 +105,7 @@ export const taskList = {
|
|||||||
34:{ serviceID :18 ,code :'C7-8-2' ,name :'培训与宣贯工作' ,basicParam :'培训与宣贯次数' ,required :false ,unit :'万元/次' ,conversion :10000 ,maxPrice :1 ,minPrice :0.5 ,defPrice :0.75 ,desc :'组织培训与宣贯'},
|
34:{ serviceID :18 ,code :'C7-8-2' ,name :'培训与宣贯工作' ,basicParam :'培训与宣贯次数' ,required :false ,unit :'万元/次' ,conversion :10000 ,maxPrice :1 ,minPrice :0.5 ,defPrice :0.75 ,desc :'组织培训与宣贯'},
|
||||||
35: { serviceID: 18, code: 'C7-9', name: '定额测试与验证', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
|
35: { serviceID: 18, code: 'C7-9', name: '定额测试与验证', basicParam: '', required: false, unit: '%', conversion: 0.01, maxPrice: 50, minPrice: 30, defPrice: 40, desc: '' },
|
||||||
};
|
};
|
||||||
const expertList = {
|
export const expertList = {
|
||||||
0: { code: 'C9-1-1', name: '技术员及其他', maxPrice: 800, minPrice: 600, defPrice: 700, manageCoe: 2.3 },
|
0: { code: 'C9-1-1', name: '技术员及其他', maxPrice: 800, minPrice: 600, defPrice: 700, manageCoe: 2.3 },
|
||||||
1: { code: 'C9-1-2', name: '助理工程师', maxPrice: 1000, minPrice: 800, defPrice: 900, manageCoe: 2.3 },
|
1: { code: 'C9-1-2', name: '助理工程师', maxPrice: 1000, minPrice: 800, defPrice: 900, manageCoe: 2.3 },
|
||||||
2: { code: 'C9-1-3', name: '中级工程师或二级造价工程师', maxPrice: 1500, minPrice: 1000, defPrice: 1250, manageCoe: 2.2 },
|
2: { code: 'C9-1-3', name: '中级工程师或二级造价工程师', maxPrice: 1500, minPrice: 1000, defPrice: 1250, manageCoe: 2.2 },
|
||||||
|
|||||||
@ -134,11 +134,66 @@
|
|||||||
user-select: text;
|
user-select: text;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.ag-body-viewport {
|
||||||
.ag-floating-top {
|
overflow-y: scroll !important;
|
||||||
overflow-y: auto !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* When one column uses auto-height rows, keep other columns vertically centered. */
|
||||||
|
.xmMx .ag-row .ag-cell:not(.ag-cell-auto-height) .ag-cell-wrapper {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.xmMx .ag-row .ag-cell:not(.ag-cell-auto-height) .ag-cell-wrapper.ag-row-group {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header wrapping for all AG Grid columns/groups. */
|
||||||
|
.ag-theme-quartz .ag-header-cell-wrap-text .ag-header-cell-text,
|
||||||
|
.ag-theme-quartz .ag-header-group-cell-label .ag-header-group-text {
|
||||||
|
white-space: normal;
|
||||||
|
word-break: break-word;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
.zxfw-action-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zxfw-action-btn {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item-clickable {
|
||||||
|
transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item-selected {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
border-color: rgba(14, 165, 233, 0.75);
|
||||||
|
box-shadow: inset 0 0 0 1px rgba(14, 165, 233, 0.3);
|
||||||
|
background: rgba(14, 165, 233, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item-selected-drag {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.35), 0 0 0 1px rgba(14, 165, 233, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.picker-item-clickable:not(.picker-item-dragging):active {
|
||||||
|
transform: translateY(1px) scale(0.985);
|
||||||
|
border-color: rgba(14, 165, 233, 0.8);
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(14, 165, 233, 0.22);
|
||||||
|
}
|
||||||
.xmMx .editable-cell-line .ag-cell-value {
|
.xmMx .editable-cell-line .ag-cell-value {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
min-width: 84%;
|
min-width: 84%;
|
||||||
@ -154,6 +209,11 @@
|
|||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.xmMx .ag-cell.editable-cell-empty.remark-wrap-cell .ag-cell-value {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
.xmMx .editable-cell-line.ag-cell-focus .ag-cell-value,
|
||||||
.xmMx .editable-cell-line:hover .ag-cell-value {
|
.xmMx .editable-cell-line:hover .ag-cell-value {
|
||||||
border-bottom-color: #2563eb;
|
border-bottom-color: #2563eb;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user