1
This commit is contained in:
parent
d52765604e
commit
303d6d6185
@ -205,19 +205,23 @@ const projectTotalBudget = computed(() => {
|
|||||||
const budgetRefreshSignature = computed(() => {
|
const budgetRefreshSignature = computed(() => {
|
||||||
const ids = contracts.value.map(item => String(item.id || '').trim()).filter(Boolean)
|
const ids = contracts.value.map(item => String(item.id || '').trim()).filter(Boolean)
|
||||||
if (ids.length === 0) return ''
|
if (ids.length === 0) return ''
|
||||||
const keyVersionEntries = Object.entries(zxFwPricingStore.keyVersions)
|
|
||||||
return ids
|
return ids
|
||||||
.map(id => {
|
.map(id => {
|
||||||
const contractVersion = zxFwPricingStore.contractVersions[id] || 0
|
|
||||||
const additionalMainKey = `htExtraFee-${id}-additional-work`
|
const additionalMainKey = `htExtraFee-${id}-additional-work`
|
||||||
const reserveMainKey = `htExtraFee-${id}-reserve`
|
const reserveMainKey = `htExtraFee-${id}-reserve`
|
||||||
const mainKeySig = `${zxFwPricingStore.getKeyVersion(additionalMainKey)}:${zxFwPricingStore.getKeyVersion(reserveMainKey)}`
|
const contractState = zxFwPricingStore.contracts[id] || null
|
||||||
const methodKeySig = keyVersionEntries
|
const addMain = zxFwPricingStore.htFeeMainStates[additionalMainKey] || null
|
||||||
.filter(([key]) => key.startsWith(`${additionalMainKey}-`) || key.startsWith(`${reserveMainKey}-`))
|
const reserveMain = zxFwPricingStore.htFeeMainStates[reserveMainKey] || null
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
const addMethods = zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null
|
||||||
.map(([key, version]) => `${key}:${version}`)
|
const reserveMethods = zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
|
||||||
.join(',')
|
return JSON.stringify({
|
||||||
return `${id}:${contractVersion}:${mainKeySig}:${methodKeySig}`
|
id,
|
||||||
|
contractState,
|
||||||
|
addMain,
|
||||||
|
reserveMain,
|
||||||
|
addMethods,
|
||||||
|
reserveMethods
|
||||||
|
})
|
||||||
})
|
})
|
||||||
.join('|')
|
.join('|')
|
||||||
})
|
})
|
||||||
@ -1881,3 +1885,4 @@ watch(budgetRefreshSignature, (next, prev) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|||||||
@ -36,17 +36,17 @@ const isReserveFee = computed(() => {
|
|||||||
if (mainKey) return mainKey.endsWith('-reserve')
|
if (mainKey) return mainKey.endsWith('-reserve')
|
||||||
return String(props.storageKey || '').includes('-reserve')
|
return String(props.storageKey || '').includes('-reserve')
|
||||||
})
|
})
|
||||||
const contractVersion = computed(() => {
|
const contractStateSignature = computed(() => {
|
||||||
const contractId = contractIdText.value
|
const contractId = contractIdText.value
|
||||||
if (!contractId) return 0
|
if (!contractId) return ''
|
||||||
return zxFwPricingStore.contractVersions[contractId] || 0
|
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
|
||||||
})
|
})
|
||||||
const additionalWorkKeyVersion = computed(() => {
|
const additionalWorkStateSignature = computed(() => {
|
||||||
if (!isReserveFee.value) return 0
|
if (!isReserveFee.value) return ''
|
||||||
const contractId = contractIdText.value
|
const contractId = contractIdText.value
|
||||||
if (!contractId) return 0
|
if (!contractId) return ''
|
||||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||||
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
|
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
|
||||||
})
|
})
|
||||||
const baseLabel = computed(() =>
|
const baseLabel = computed(() =>
|
||||||
isReserveFee.value ? '基数(咨询服务总计 + 附加工作费总计)' : '基数(所有服务费预算合计)'
|
isReserveFee.value ? '基数(咨询服务总计 + 附加工作费总计)' : '基数(所有服务费预算合计)'
|
||||||
@ -180,8 +180,8 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch([contractVersion, additionalWorkKeyVersion], ([nextContract, nextAdditional], [prevContract, prevAdditional]) => {
|
watch([contractStateSignature, additionalWorkStateSignature], ([nextContractSig, nextAdditionalSig], [prevContractSig, prevAdditionalSig]) => {
|
||||||
if (nextContract === prevContract && nextAdditional === prevAdditional) return
|
if (nextContractSig === prevContractSig && nextAdditionalSig === prevAdditionalSig) return
|
||||||
void ensureContractLoaded()
|
void ensureContractLoaded()
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -230,3 +230,4 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@ -159,16 +159,15 @@ const refreshContractBudget = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const budgetRefreshSignature = computed(() => {
|
const budgetRefreshSignature = computed(() => {
|
||||||
const contractVersion = zxFwPricingStore.contractVersions[props.contractId] || 0
|
|
||||||
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
|
const additionalMainKey = `htExtraFee-${props.contractId}-additional-work`
|
||||||
const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
|
const reserveMainKey = `htExtraFee-${props.contractId}-reserve`
|
||||||
const keyVersionEntries = Object.entries(zxFwPricingStore.keyVersions)
|
return JSON.stringify({
|
||||||
const methodKeySig = keyVersionEntries
|
contractState: zxFwPricingStore.contracts[props.contractId] || null,
|
||||||
.filter(([key]) => key.startsWith(`${additionalMainKey}-`) || key.startsWith(`${reserveMainKey}-`))
|
addMain: zxFwPricingStore.htFeeMainStates[additionalMainKey] || null,
|
||||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
reserveMain: zxFwPricingStore.htFeeMainStates[reserveMainKey] || null,
|
||||||
.map(([key, version]) => `${key}:${version}`)
|
addMethods: zxFwPricingStore.htFeeMethodStates[additionalMainKey] || null,
|
||||||
.join(',')
|
reserveMethods: zxFwPricingStore.htFeeMethodStates[reserveMainKey] || null
|
||||||
return `${contractVersion}:${zxFwPricingStore.getKeyVersion(additionalMainKey)}:${zxFwPricingStore.getKeyVersion(reserveMainKey)}:${methodKeySig}`
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
const scheduleRefreshContractBudget = () => {
|
const scheduleRefreshContractBudget = () => {
|
||||||
@ -349,3 +348,4 @@ onBeforeUnmount(() => {
|
|||||||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch } from 'vue'
|
import { computed, defineComponent, h, nextTick, onActivated, onMounted, ref, shallowRef, watch } from 'vue'
|
||||||
import type { ComponentPublicInstance, PropType } from 'vue'
|
import type { PropType } from 'vue'
|
||||||
import { AgGridVue } from 'ag-grid-vue3'
|
import { AgGridVue } from 'ag-grid-vue3'
|
||||||
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
import type { ColDef, GridApi, GridOptions, GridReadyEvent, ICellRendererParams } from 'ag-grid-community'
|
||||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||||
@ -24,7 +24,6 @@ import {
|
|||||||
AlertDialogPortal,
|
AlertDialogPortal,
|
||||||
AlertDialogRoot,
|
AlertDialogRoot,
|
||||||
AlertDialogTitle,
|
AlertDialogTitle,
|
||||||
|
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||||
@ -101,9 +100,11 @@ type ServiceListItem = {
|
|||||||
workDay?: boolean | null
|
workDay?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 仅保留明确的 boolean,其他值统一转 null。 */
|
||||||
const toNullableBoolean = (value: unknown): boolean | null =>
|
const toNullableBoolean = (value: unknown): boolean | null =>
|
||||||
typeof value === 'boolean' ? value : null
|
typeof value === 'boolean' ? value : null
|
||||||
|
|
||||||
|
/** 解析方法开关,未配置时返回默认值。 */
|
||||||
const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) =>
|
const resolveMethodEnabled = (value: boolean | null | undefined, fallback = true) =>
|
||||||
typeof value === 'boolean' ? value : fallback
|
typeof value === 'boolean' ? value : fallback
|
||||||
|
|
||||||
@ -141,11 +142,15 @@ const serviceById = computed(() => new Map(serviceDict.value.map(item => [item.i
|
|||||||
const serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id])))
|
const serviceIdByCode = computed(() => new Map(serviceDict.value.map(item => [item.code, item.id])))
|
||||||
const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|'))
|
const serviceIdSignature = computed(() => serviceDict.value.map(item => item.id).join('|'))
|
||||||
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: '', name: '小计' }
|
const fixedBudgetRow: Pick<DetailRow, 'id' | 'code' | 'name'> = { id: 'fixed-budget-c', code: '', name: '小计' }
|
||||||
|
|
||||||
|
/** 判断是否固定汇总行(小计行)。 */
|
||||||
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
|
const isFixedRow = (row?: DetailRow | null) => row?.id === fixedBudgetRow.id
|
||||||
|
|
||||||
const selectedIds = computed<string[]>(() => zxFwPricingStore.contracts[props.contractId]?.selectedIds || [])
|
/**
|
||||||
const detailRows = computed<DetailRow[]>(() =>
|
* 统一把 store/raw row 转成页面使用的 DetailRow 结构。
|
||||||
(zxFwPricingStore.contracts[props.contractId]?.detailRows || []).map(row => ({
|
* 被 detailRows 计算属性和 getCurrentContractState 复用,避免重复映射逻辑。
|
||||||
|
*/
|
||||||
|
const normalizeDetailRow = (row: Partial<DetailRow>): DetailRow => ({
|
||||||
id: String(row.id || ''),
|
id: String(row.id || ''),
|
||||||
code: row.code || '',
|
code: row.code || '',
|
||||||
name: row.name || '',
|
name: row.name || '',
|
||||||
@ -155,30 +160,26 @@ const detailRows = computed<DetailRow[]>(() =>
|
|||||||
workload: typeof row.workload === 'number' ? row.workload : null,
|
workload: typeof row.workload === 'number' ? row.workload : null,
|
||||||
hourly: typeof row.hourly === 'number' ? row.hourly : null,
|
hourly: typeof row.hourly === 'number' ? row.hourly : null,
|
||||||
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
|
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
|
||||||
finalFee: typeof (row as any).finalFee === 'number' ? (row as any).finalFee : null,
|
finalFee: typeof row.finalFee === 'number' ? row.finalFee : null,
|
||||||
actions: row.actions
|
actions: row.actions
|
||||||
}))
|
})
|
||||||
|
|
||||||
|
const selectedIds = computed<string[]>(() => zxFwPricingStore.contracts[props.contractId]?.selectedIds || [])
|
||||||
|
const detailRows = computed<DetailRow[]>(() =>
|
||||||
|
(zxFwPricingStore.contracts[props.contractId]?.detailRows || []).map(row => normalizeDetailRow(row as Partial<DetailRow>))
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前合同状态的“可变副本”。
|
||||||
|
* 所有写回前都先读这里,避免直接修改 store 引用对象。
|
||||||
|
*/
|
||||||
const getCurrentContractState = (): ZxFwViewState => {
|
const getCurrentContractState = (): ZxFwViewState => {
|
||||||
const current = zxFwPricingStore.getContractState(props.contractId)
|
const current = zxFwPricingStore.getContractState(props.contractId)
|
||||||
if (current) {
|
if (current) {
|
||||||
return {
|
return {
|
||||||
selectedIds: Array.isArray(current.selectedIds) ? [...current.selectedIds] : [],
|
selectedIds: Array.isArray(current.selectedIds) ? [...current.selectedIds] : [],
|
||||||
selectedCodes: Array.isArray(current.selectedCodes) ? [...current.selectedCodes] : [],
|
selectedCodes: Array.isArray(current.selectedCodes) ? [...current.selectedCodes] : [],
|
||||||
detailRows: (current.detailRows || []).map(row => ({
|
detailRows: (current.detailRows || []).map(row => normalizeDetailRow(row as Partial<DetailRow>))
|
||||||
id: String(row.id || ''),
|
|
||||||
code: row.code || '',
|
|
||||||
name: row.name || '',
|
|
||||||
process: String(row.id || '') === fixedBudgetRow.id ? null : (Number(row.process) === 1 ? 1 : 0),
|
|
||||||
investScale: typeof row.investScale === 'number' ? row.investScale : null,
|
|
||||||
landScale: typeof row.landScale === 'number' ? row.landScale : null,
|
|
||||||
workload: typeof row.workload === 'number' ? row.workload : null,
|
|
||||||
hourly: typeof row.hourly === 'number' ? row.hourly : null,
|
|
||||||
subtotal: typeof row.subtotal === 'number' ? row.subtotal : null,
|
|
||||||
finalFee: typeof (row as any).finalFee === 'number' ? (row as any).finalFee : null,
|
|
||||||
actions: row.actions
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@ -187,94 +188,21 @@ const getCurrentContractState = (): ZxFwViewState => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 统一写回当前合同状态到 store。 */
|
||||||
const setCurrentContractState = async (nextState: ZxFwViewState) => {
|
const setCurrentContractState = async (nextState: ZxFwViewState) => {
|
||||||
await zxFwPricingStore.setContractState(props.contractId, nextState)
|
await zxFwPricingStore.setContractState(props.contractId, nextState)
|
||||||
}
|
}
|
||||||
|
|
||||||
const gridApi = shallowRef<GridApi<DetailRow> | null>(null)
|
const gridApi = shallowRef<GridApi<DetailRow> | null>(null)
|
||||||
|
/** 记录 grid api,供编辑后局部刷新固定行使用。 */
|
||||||
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
|
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||||
gridApi.value = event.api
|
gridApi.value = event.api
|
||||||
}
|
}
|
||||||
|
|
||||||
const pickerOpen = ref(false)
|
|
||||||
const pickerTempIds = ref<string[]>([])
|
|
||||||
const pickerSearch = ref('')
|
|
||||||
const pickerListScrollRef = ref<HTMLElement | null>(null)
|
|
||||||
const clearConfirmOpen = ref(false)
|
const clearConfirmOpen = ref(false)
|
||||||
const pendingClearServiceId = ref<string | null>(null)
|
const pendingClearServiceId = ref<string | null>(null)
|
||||||
const deleteConfirmOpen = ref(false)
|
const deleteConfirmOpen = ref(false)
|
||||||
const pendingDeleteServiceId = ref<string | null>(null)
|
const pendingDeleteServiceId = ref<string | null>(null)
|
||||||
const dragSelecting = ref(false)
|
|
||||||
const dragMoved = ref(false)
|
|
||||||
let dragSelectChecked = false
|
|
||||||
const dragBaseIds = ref<string[]>([])
|
|
||||||
const dragStartPoint = ref({ x: 0, y: 0 })
|
|
||||||
const dragCurrentPoint = ref({ x: 0, y: 0 })
|
|
||||||
const pickerItemElMap = new Map<string, HTMLElement>()
|
|
||||||
let dragTouchedIds = new Set<string>()
|
|
||||||
let dragAutoScrollRafId: number | null = null
|
|
||||||
|
|
||||||
const DRAG_AUTO_SCROLL_EDGE = 36
|
|
||||||
const DRAG_AUTO_SCROLL_MAX_STEP = 16
|
|
||||||
|
|
||||||
const stopDragAutoScroll = () => {
|
|
||||||
if (dragAutoScrollRafId !== null) {
|
|
||||||
cancelAnimationFrame(dragAutoScrollRafId)
|
|
||||||
dragAutoScrollRafId = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolveDragAutoScrollStep = () => {
|
|
||||||
const scroller = pickerListScrollRef.value
|
|
||||||
if (!scroller) return 0
|
|
||||||
const rect = scroller.getBoundingClientRect()
|
|
||||||
const pointerY = dragCurrentPoint.value.y
|
|
||||||
let step = 0
|
|
||||||
|
|
||||||
if (pointerY > rect.bottom - DRAG_AUTO_SCROLL_EDGE) {
|
|
||||||
const ratio = Math.min(2, (pointerY - (rect.bottom - DRAG_AUTO_SCROLL_EDGE)) / DRAG_AUTO_SCROLL_EDGE)
|
|
||||||
step = Math.max(2, Math.round(ratio * DRAG_AUTO_SCROLL_MAX_STEP))
|
|
||||||
} else if (pointerY < rect.top + DRAG_AUTO_SCROLL_EDGE) {
|
|
||||||
const ratio = Math.min(2, ((rect.top + DRAG_AUTO_SCROLL_EDGE) - pointerY) / DRAG_AUTO_SCROLL_EDGE)
|
|
||||||
step = -Math.max(2, Math.round(ratio * DRAG_AUTO_SCROLL_MAX_STEP))
|
|
||||||
}
|
|
||||||
return step
|
|
||||||
}
|
|
||||||
|
|
||||||
const runDragAutoScroll = () => {
|
|
||||||
if (!dragSelecting.value) {
|
|
||||||
stopDragAutoScroll()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const scroller = pickerListScrollRef.value
|
|
||||||
if (scroller) {
|
|
||||||
const step = resolveDragAutoScrollStep()
|
|
||||||
if (step !== 0) {
|
|
||||||
const prev = scroller.scrollTop
|
|
||||||
scroller.scrollTop += step
|
|
||||||
if (scroller.scrollTop !== prev) {
|
|
||||||
applyDragSelectionByRect()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dragAutoScrollRafId = requestAnimationFrame(runDragAutoScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDragAutoScroll = () => {
|
|
||||||
if (dragAutoScrollRafId !== null) return
|
|
||||||
dragAutoScrollRafId = requestAnimationFrame(runDragAutoScroll)
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedServiceText = computed(() => {
|
|
||||||
if (selectedIds.value.length === 0) return ''
|
|
||||||
const names = selectedIds.value
|
|
||||||
.map(id => serviceById.value.get(id)?.name || '')
|
|
||||||
.filter(Boolean)
|
|
||||||
if (names.length <= 2) return names.join('、')
|
|
||||||
return `${names.slice(0, 2).join('、')} 等 ${names.length} 项`
|
|
||||||
})
|
|
||||||
|
|
||||||
const pendingClearServiceName = computed(() => {
|
const pendingClearServiceName = computed(() => {
|
||||||
if (!pendingClearServiceId.value) return ''
|
if (!pendingClearServiceId.value) return ''
|
||||||
@ -294,20 +222,24 @@ const pendingDeleteServiceName = computed(() => {
|
|||||||
return pendingDeleteServiceId.value
|
return pendingDeleteServiceId.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/** 清空确认框开关回调。 */
|
||||||
const handleClearConfirmOpenChange = (open: boolean) => {
|
const handleClearConfirmOpenChange = (open: boolean) => {
|
||||||
clearConfirmOpen.value = open
|
clearConfirmOpen.value = open
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除确认框开关回调。 */
|
||||||
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
const handleDeleteConfirmOpenChange = (open: boolean) => {
|
||||||
deleteConfirmOpen.value = open
|
deleteConfirmOpen.value = open
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 触发“恢复默认”二次确认。 */
|
||||||
const requestClearRow = (row: DetailRow) => {
|
const requestClearRow = (row: DetailRow) => {
|
||||||
if (isFixedRow(row)) return
|
if (isFixedRow(row)) return
|
||||||
pendingClearServiceId.value = row.id
|
pendingClearServiceId.value = row.id
|
||||||
clearConfirmOpen.value = true
|
clearConfirmOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 执行“恢复默认”并关闭确认框。 */
|
||||||
const confirmClearRow = async () => {
|
const confirmClearRow = async () => {
|
||||||
const id = pendingClearServiceId.value
|
const id = pendingClearServiceId.value
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@ -322,12 +254,14 @@ const confirmClearRow = async () => {
|
|||||||
pendingClearServiceId.value = null
|
pendingClearServiceId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 触发“删除服务”二次确认。 */
|
||||||
const requestDeleteRow = (row: DetailRow) => {
|
const requestDeleteRow = (row: DetailRow) => {
|
||||||
if (isFixedRow(row)) return
|
if (isFixedRow(row)) return
|
||||||
pendingDeleteServiceId.value = row.id
|
pendingDeleteServiceId.value = row.id
|
||||||
deleteConfirmOpen.value = true
|
deleteConfirmOpen.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 执行删除并关闭确认框。 */
|
||||||
const confirmDeleteRow = async () => {
|
const confirmDeleteRow = async () => {
|
||||||
const id = pendingDeleteServiceId.value
|
const id = pendingDeleteServiceId.value
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@ -342,39 +276,33 @@ const confirmDeleteRow = async () => {
|
|||||||
pendingDeleteServiceId.value = null
|
pendingDeleteServiceId.value = null
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredServiceDict = computed(() => {
|
|
||||||
const keyword = pickerSearch.value.trim()
|
|
||||||
if (!keyword) return serviceDict.value
|
|
||||||
return serviceDict.value.filter(item => item.code.includes(keyword) || item.name.includes(keyword))
|
|
||||||
})
|
|
||||||
|
|
||||||
const dragRectStyle = computed(() => {
|
|
||||||
if (!dragSelecting.value) return {}
|
|
||||||
const left = Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x)
|
|
||||||
const top = Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
|
||||||
const width = Math.abs(dragCurrentPoint.value.x - dragStartPoint.value.x)
|
|
||||||
const height = Math.abs(dragCurrentPoint.value.y - dragStartPoint.value.y)
|
|
||||||
return {
|
|
||||||
left: `${left}px`,
|
|
||||||
top: `${top}px`,
|
|
||||||
width: `${width}px`,
|
|
||||||
height: `${height}px`
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
|
/** 表格数字输入解析器(保留 3 位小数)。 */
|
||||||
const numericParser = (newValue: any): number | null => {
|
const numericParser = (newValue: any): number | null => {
|
||||||
return parseNumberOrNull(newValue, { precision: 3 })
|
return parseNumberOrNull(newValue, { precision: 3 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 类型守卫:有限数字。 */
|
||||||
const isFiniteNumberValue = (value: unknown): value is number =>
|
const isFiniteNumberValue = (value: unknown): value is number =>
|
||||||
typeof value === 'number' && Number.isFinite(value)
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
|
|
||||||
|
/** 可空数字求和:全为空返回 null。 */
|
||||||
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
|
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
|
||||||
const validValues = values.filter(isFiniteNumberValue)
|
const validValues = values.filter(isFiniteNumberValue)
|
||||||
if (validValues.length === 0) return null
|
if (validValues.length === 0) return null
|
||||||
return addNumbers(...validValues)
|
return addNumbers(...validValues)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSameNullableNumber = (a: number | null | undefined, b: number | null | undefined, precision = 3) => {
|
||||||
|
if (a == null && b == null) return true
|
||||||
|
if (a == null || b == null) return false
|
||||||
|
return roundTo(a, precision) === roundTo(b, precision)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 读取服务词典中的计价方法开关(规模/工作量/工时),并应用默认值。
|
||||||
|
* 该方法被 sanitizePricingTotalsByService 复用。
|
||||||
|
*/
|
||||||
const getServiceMethodTypeById = (serviceId: string) => {
|
const getServiceMethodTypeById = (serviceId: string) => {
|
||||||
const type = serviceById.value.get(serviceId)?.type
|
const type = serviceById.value.get(serviceId)?.type
|
||||||
const scale = resolveMethodEnabled(type?.scale, defaultServiceMethodType.scale)
|
const scale = resolveMethodEnabled(type?.scale, defaultServiceMethodType.scale)
|
||||||
@ -384,6 +312,10 @@ const getServiceMethodTypeById = (serviceId: string) => {
|
|||||||
return { scale, onlyCostScale, amount, workDay }
|
return { scale, onlyCostScale, amount, workDay }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按服务方法开关过滤计价合计。
|
||||||
|
* 被 clearRowValues/fillPricingTotalsForServiceIds 等入口复用。
|
||||||
|
*/
|
||||||
const sanitizePricingTotalsByService = (serviceId: string, totals: PricingMethodTotals): PricingMethodTotals => {
|
const sanitizePricingTotalsByService = (serviceId: string, totals: PricingMethodTotals): PricingMethodTotals => {
|
||||||
const methodType = getServiceMethodTypeById(serviceId)
|
const methodType = getServiceMethodTypeById(serviceId)
|
||||||
const isScaleEnabled = methodType.scale
|
const isScaleEnabled = methodType.scale
|
||||||
@ -396,6 +328,10 @@ const sanitizePricingTotalsByService = (serviceId: string, totals: PricingMethod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 对 4 个计价字段做开关过滤(例如 onlyCostScale 会禁用用地规模法)。
|
||||||
|
* 主要在“选择服务、加载历史值”时复用。
|
||||||
|
*/
|
||||||
const sanitizePricingFieldsByService = (
|
const sanitizePricingFieldsByService = (
|
||||||
serviceId: string,
|
serviceId: string,
|
||||||
values: Pick<DetailRow, 'investScale' | 'landScale' | 'workload' | 'hourly'>
|
values: Pick<DetailRow, 'investScale' | 'landScale' | 'workload' | 'hourly'>
|
||||||
@ -409,29 +345,58 @@ const sanitizePricingFieldsByService = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PricingMethodField = 'investScale' | 'landScale' | 'workload' | 'hourly'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算某列在“非固定行”上的合计。
|
||||||
|
* 被 getMethodTotal 和固定行汇总逻辑复用。
|
||||||
|
*/
|
||||||
const getMethodTotalFromRows = (
|
const getMethodTotalFromRows = (
|
||||||
rows: DetailRow[],
|
rows: DetailRow[],
|
||||||
field: 'investScale' | 'landScale' | 'workload' | 'hourly'
|
field: PricingMethodField
|
||||||
) => sumNullableNumbers(
|
) => sumNullableNumbers(
|
||||||
rows
|
rows
|
||||||
.filter(row => !isFixedRow(row))
|
.filter(row => !isFixedRow(row))
|
||||||
.map(row => row[field])
|
.map(row => row[field])
|
||||||
)
|
)
|
||||||
|
|
||||||
const getMethodTotal = (field: 'investScale' | 'landScale' | 'workload' | 'hourly') =>
|
/** 当前页面行数据中某计价列总计(仅非固定行)。 */
|
||||||
|
const getMethodTotal = (field: PricingMethodField) =>
|
||||||
getMethodTotalFromRows(detailRows.value, field)
|
getMethodTotalFromRows(detailRows.value, field)
|
||||||
|
|
||||||
const getFixedRowSubtotal = () =>
|
/**
|
||||||
sumNullableNumbers([
|
* 生成 4 个计价法列的公共配置,减少重复定义。
|
||||||
getMethodTotal('investScale'),
|
* 值来源统一:固定行取列合计,普通行取自身字段。
|
||||||
getMethodTotal('landScale'),
|
*/
|
||||||
getMethodTotal('workload'),
|
const createMethodColumn = (
|
||||||
getMethodTotal('hourly')
|
headerName: string,
|
||||||
])
|
field: PricingMethodField,
|
||||||
|
minWidth: number
|
||||||
|
): ColDef<DetailRow> => ({
|
||||||
|
headerName,
|
||||||
|
field,
|
||||||
|
headerClass: 'ag-right-aligned-header',
|
||||||
|
minWidth,
|
||||||
|
flex: 1.5,
|
||||||
|
cellClass: 'ag-right-aligned-cell',
|
||||||
|
editable: false,
|
||||||
|
valueGetter: params => {
|
||||||
|
if (!params.data) return null
|
||||||
|
if (isFixedRow(params.data)) return getMethodTotal(field)
|
||||||
|
return params.data[field]
|
||||||
|
},
|
||||||
|
valueParser: params => numericParser(params.newValue),
|
||||||
|
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取某服务对应四个计价页的存储键。 */
|
||||||
const getPricingPaneStorageKeys = (serviceId: string) =>
|
const getPricingPaneStorageKeys = (serviceId: string) =>
|
||||||
zxFwPricingStore.getServicePricingStorageKeys(props.contractId, serviceId)
|
zxFwPricingStore.getServicePricingStorageKeys(props.contractId, serviceId)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清空某服务在 4 个计价页的缓存与持久化数据。
|
||||||
|
* 会写入短期 skip/force 标记,避免“刚删完又被旧数据回填”。
|
||||||
|
*/
|
||||||
const clearPricingPaneValues = async (serviceId: string) => {
|
const clearPricingPaneValues = async (serviceId: string) => {
|
||||||
const keys = getPricingPaneStorageKeys(serviceId)
|
const keys = getPricingPaneStorageKeys(serviceId)
|
||||||
const clearIssuedAt = Date.now()
|
const clearIssuedAt = Date.now()
|
||||||
@ -446,6 +411,10 @@ const clearPricingPaneValues = async (serviceId: string) => {
|
|||||||
await Promise.all(keys.map(key => kvStore.removeItem(key)))
|
await Promise.all(keys.map(key => kvStore.removeItem(key)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复单行到默认计价结果(“恢复默认”按钮)。
|
||||||
|
* 执行顺序:关闭编辑页 -> 清缓存/持久层 -> 重新生成默认明细 -> 回填合计。
|
||||||
|
*/
|
||||||
const clearRowValues = async (row: DetailRow) => {
|
const clearRowValues = async (row: DetailRow) => {
|
||||||
if (isFixedRow(row)) return
|
if (isFixedRow(row)) return
|
||||||
|
|
||||||
@ -484,6 +453,7 @@ const clearRowValues = async (row: DetailRow) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 打开服务编辑子页。 */
|
||||||
const openEditTab = (row: DetailRow) => {
|
const openEditTab = (row: DetailRow) => {
|
||||||
const serviceType = serviceById.value.get(row.id)?.type
|
const serviceType = serviceById.value.get(row.id)?.type
|
||||||
tabStore.openTab({
|
tabStore.openTab({
|
||||||
@ -501,6 +471,7 @@ const openEditTab = (row: DetailRow) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 删除单行服务(本质是更新 selectedIds)。 */
|
||||||
const removeRow = async (row: DetailRow) => {
|
const removeRow = async (row: DetailRow) => {
|
||||||
if (isFixedRow(row)) return
|
if (isFixedRow(row)) return
|
||||||
const nextIds = selectedIds.value.filter(id => id !== row.id)
|
const nextIds = selectedIds.value.filter(id => id !== row.id)
|
||||||
@ -604,8 +575,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
flex: 3,
|
flex: 3,
|
||||||
wrapText: true,
|
wrapText: true,
|
||||||
autoHeight: true,
|
autoHeight: true,
|
||||||
cellStyle:{ 'line-height': 1.6
|
cellStyle: { 'line-height': 1.6 },
|
||||||
},
|
|
||||||
valueGetter: params => {
|
valueGetter: params => {
|
||||||
if (!params.data) return ''
|
if (!params.data) return ''
|
||||||
if (isFixedRow(params.data)) return ''
|
if (isFixedRow(params.data)) return ''
|
||||||
@ -634,76 +604,10 @@ const columnDefs: ColDef<DetailRow>[] = [
|
|||||||
},
|
},
|
||||||
cellRenderer: ProcessCellRenderer
|
cellRenderer: ProcessCellRenderer
|
||||||
},
|
},
|
||||||
{
|
createMethodColumn('投资规模法', 'investScale', 100),
|
||||||
headerName: '投资规模法',
|
createMethodColumn('用地规模法', 'landScale', 100),
|
||||||
field: 'investScale',
|
createMethodColumn('工作量法', 'workload', 90),
|
||||||
headerClass: 'ag-right-aligned-header',
|
createMethodColumn('工时法', 'hourly', 90),
|
||||||
minWidth: 100,
|
|
||||||
flex: 1.5,
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
editable: false,
|
|
||||||
|
|
||||||
valueGetter: params => {
|
|
||||||
if (!params.data) return null
|
|
||||||
if (isFixedRow(params.data)) return getMethodTotal('investScale')
|
|
||||||
return params.data.investScale
|
|
||||||
},
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
|
||||||
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: '用地规模法',
|
|
||||||
field: 'landScale',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 100,
|
|
||||||
flex: 1.5,
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
editable: false,
|
|
||||||
|
|
||||||
valueGetter: params => {
|
|
||||||
if (!params.data) return null
|
|
||||||
if (isFixedRow(params.data)) return getMethodTotal('landScale')
|
|
||||||
return params.data.landScale
|
|
||||||
},
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
|
||||||
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: '工作量法',
|
|
||||||
field: 'workload',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 90,
|
|
||||||
flex: 1.5,
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
editable: false,
|
|
||||||
|
|
||||||
valueGetter: params => {
|
|
||||||
if (!params.data) return null
|
|
||||||
if (isFixedRow(params.data)) return getMethodTotal('workload')
|
|
||||||
return params.data.workload
|
|
||||||
},
|
|
||||||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
|
||||||
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
|
|
||||||
},
|
|
||||||
{
|
|
||||||
headerName: '工时法',
|
|
||||||
field: 'hourly',
|
|
||||||
headerClass: 'ag-right-aligned-header',
|
|
||||||
minWidth: 90,
|
|
||||||
flex: 1.5,
|
|
||||||
cellClass: 'ag-right-aligned-cell',
|
|
||||||
editable: false,
|
|
||||||
|
|
||||||
valueGetter: params => {
|
|
||||||
if (!params.data) return null
|
|
||||||
if (isFixedRow(params.data)) return getMethodTotal('hourly')
|
|
||||||
return params.data.hourly
|
|
||||||
},
|
|
||||||
// editable: params => !params.node?.rowPinned && !isFixedRow(params.data),
|
|
||||||
valueParser: params => numericParser(params.newValue),
|
|
||||||
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
headerName: '小计',
|
headerName: '小计',
|
||||||
field: 'subtotal',
|
field: 'subtotal',
|
||||||
@ -802,21 +706,17 @@ const detailGridOptions: GridOptions<DetailRow> = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const applyFixedRowTotals = (rows: DetailRow[]) => {
|
/**
|
||||||
|
* 只重算固定行(小计行)汇总,不覆盖普通行 finalFee。
|
||||||
|
* 主要用于用户手动编辑 finalFee 后的同步场景。
|
||||||
|
*/
|
||||||
|
const applyFixedRowSummary = (rows: DetailRow[]) => {
|
||||||
const nextInvestScale = getMethodTotalFromRows(rows, 'investScale')
|
const nextInvestScale = getMethodTotalFromRows(rows, 'investScale')
|
||||||
const nextLandScale = getMethodTotalFromRows(rows, 'landScale')
|
const nextLandScale = getMethodTotalFromRows(rows, 'landScale')
|
||||||
const nextWorkload = getMethodTotalFromRows(rows, 'workload')
|
const nextWorkload = getMethodTotalFromRows(rows, 'workload')
|
||||||
const nextHourly = getMethodTotalFromRows(rows, 'hourly')
|
const nextHourly = getMethodTotalFromRows(rows, 'hourly')
|
||||||
// 先更新普通行:finalFee 跟随小计(若未手动编辑过或值为 null 则同步)
|
const totalFinalFee = sumNullableNumbers(rows.filter(r => !isFixedRow(r)).map(r => r.finalFee))
|
||||||
const updatedRows = rows.map(row => {
|
return rows.map(row =>
|
||||||
if (isFixedRow(row)) return row
|
|
||||||
const rowSubtotal = sumNullableNumbers([row.investScale, row.landScale, row.workload, row.hourly])
|
|
||||||
const nextFinalFee = row.finalFee != null ? row.finalFee : (rowSubtotal != null ? roundTo(rowSubtotal, 2) : null)
|
|
||||||
return { ...row, finalFee: nextFinalFee }
|
|
||||||
})
|
|
||||||
// 再计算固定行汇总
|
|
||||||
const totalFinalFee = sumNullableNumbers(updatedRows.filter(r => !isFixedRow(r)).map(r => r.finalFee))
|
|
||||||
return updatedRows.map(row =>
|
|
||||||
isFixedRow(row)
|
isFixedRow(row)
|
||||||
? {
|
? {
|
||||||
...row,
|
...row,
|
||||||
@ -831,11 +731,33 @@ const applyFixedRowTotals = (rows: DetailRow[]) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计价法金额发生变化时调用:
|
||||||
|
* 1) 普通行 finalFee 强制同步 subtotal
|
||||||
|
* 2) 固定行汇总(四列+确认金额)统一重算
|
||||||
|
*/
|
||||||
|
const applyFixedRowTotals = (rows: DetailRow[]) => {
|
||||||
|
const syncedRows = rows.map(row => {
|
||||||
|
if (isFixedRow(row)) return row
|
||||||
|
const rowSubtotal = sumNullableNumbers([row.investScale, row.landScale, row.workload, row.hourly])
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
finalFee: rowSubtotal != null ? roundTo(rowSubtotal, 2) : null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return applyFixedRowSummary(syncedRows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前已选服务 id(排除固定小计行)。 */
|
||||||
const getSelectedServiceIdsWithoutFixed = () =>
|
const getSelectedServiceIdsWithoutFixed = () =>
|
||||||
detailRows.value
|
detailRows.value
|
||||||
.filter(row => !isFixedRow(row))
|
.filter(row => !isFixedRow(row))
|
||||||
.map(row => String(row.id))
|
.map(row => String(row.id))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为当前选中服务确保“计价明细行”存在(必要时创建默认明细)。
|
||||||
|
* 初始化、勾选变化后都会调用。
|
||||||
|
*/
|
||||||
const ensurePricingDetailRowsForCurrentSelection = async () => {
|
const ensurePricingDetailRowsForCurrentSelection = async () => {
|
||||||
const serviceIds = getSelectedServiceIdsWithoutFixed()
|
const serviceIds = getSelectedServiceIdsWithoutFixed()
|
||||||
if (serviceIds.length === 0) return
|
if (serviceIds.length === 0) return
|
||||||
@ -846,7 +768,12 @@ const ensurePricingDetailRowsForCurrentSelection = async () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 刷新指定服务的 4 个计价法合计,并回写到 zxFw 明细。
|
||||||
|
* 计价法变更场景统一走这里,最终会触发 applyFixedRowTotals。
|
||||||
|
*/
|
||||||
const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
|
const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
|
||||||
|
|
||||||
const currentState = getCurrentContractState()
|
const currentState = getCurrentContractState()
|
||||||
const targetIds = Array.from(
|
const targetIds = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@ -859,7 +786,7 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
|
|||||||
if (targetIds.length === 0) {
|
if (targetIds.length === 0) {
|
||||||
await setCurrentContractState({
|
await setCurrentContractState({
|
||||||
...currentState,
|
...currentState,
|
||||||
detailRows: applyFixedRowTotals(currentState.detailRows)
|
detailRows: applyFixedRowSummary(currentState.detailRows)
|
||||||
})
|
})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -883,26 +810,34 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
|
|||||||
const totals = totalsRaw ? sanitizePricingTotalsByService(String(row.id), totalsRaw) : null
|
const totals = totalsRaw ? sanitizePricingTotalsByService(String(row.id), totalsRaw) : null
|
||||||
if (!totals) return row
|
if (!totals) return row
|
||||||
const newSubtotal = sumNullableNumbers([totals.investScale, totals.landScale, totals.workload, totals.hourly])
|
const newSubtotal = sumNullableNumbers([totals.investScale, totals.landScale, totals.workload, totals.hourly])
|
||||||
// 判断用户是否手动编辑过 finalFee:旧值与旧四种方法之和不一致则视为手动编辑
|
const methodChanged = !(
|
||||||
const oldSubtotal = sumNullableNumbers([row.investScale, row.landScale, row.workload, row.hourly])
|
isSameNullableNumber(row.investScale, totals.investScale)
|
||||||
const userEdited = row.finalFee != null && oldSubtotal != null
|
&& isSameNullableNumber(row.landScale, totals.landScale)
|
||||||
&& roundTo(row.finalFee, 2) !== roundTo(oldSubtotal, 2)
|
&& isSameNullableNumber(row.workload, totals.workload)
|
||||||
|
&& isSameNullableNumber(row.hourly, totals.hourly)
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
investScale: totals.investScale,
|
investScale: totals.investScale,
|
||||||
landScale: totals.landScale,
|
landScale: totals.landScale,
|
||||||
workload: totals.workload,
|
workload: totals.workload,
|
||||||
hourly: totals.hourly,
|
hourly: totals.hourly,
|
||||||
finalFee: userEdited ? row.finalFee : (newSubtotal != null ? roundTo(newSubtotal, 2) : null)
|
finalFee: methodChanged
|
||||||
|
? (newSubtotal != null ? roundTo(newSubtotal, 2) : null)
|
||||||
|
: row.finalFee
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
await setCurrentContractState({
|
await setCurrentContractState({
|
||||||
...currentState,
|
...currentState,
|
||||||
detailRows: applyFixedRowTotals(nextRows)
|
detailRows: applyFixedRowSummary(nextRows)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 应用服务勾选结果(新增/删除服务)。
|
||||||
|
* 会保留已有行可复用信息,并确保固定行始终存在。
|
||||||
|
*/
|
||||||
const applySelection = async (codes: string[]) => {
|
const applySelection = async (codes: string[]) => {
|
||||||
const currentState = getCurrentContractState()
|
const currentState = getCurrentContractState()
|
||||||
const prevSelectedSet = new Set(currentState.selectedIds || [])
|
const prevSelectedSet = new Set(currentState.selectedIds || [])
|
||||||
@ -917,6 +852,7 @@ const applySelection = async (codes: string[]) => {
|
|||||||
if (!dictItem) return null
|
if (!dictItem) return null
|
||||||
|
|
||||||
const old = existingMap.get(id)
|
const old = existingMap.get(id)
|
||||||
|
|
||||||
const nextValues = sanitizePricingFieldsByService(id, {
|
const nextValues = sanitizePricingFieldsByService(id, {
|
||||||
investScale: old?.investScale ?? null,
|
investScale: old?.investScale ?? null,
|
||||||
landScale: old?.landScale ?? null,
|
landScale: old?.landScale ?? null,
|
||||||
@ -932,6 +868,8 @@ const applySelection = async (codes: string[]) => {
|
|||||||
landScale: nextValues.landScale,
|
landScale: nextValues.landScale,
|
||||||
workload: nextValues.workload,
|
workload: nextValues.workload,
|
||||||
hourly: nextValues.hourly,
|
hourly: nextValues.hourly,
|
||||||
|
subtotal: typeof old?.subtotal === 'number' ? old.subtotal : null,
|
||||||
|
|
||||||
finalFee: typeof old?.finalFee === 'number' ? old.finalFee : null
|
finalFee: typeof old?.finalFee === 'number' ? old.finalFee : null
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@ -950,8 +888,8 @@ const applySelection = async (codes: string[]) => {
|
|||||||
landScale: typeof fixedOld?.landScale === 'number' ? fixedOld.landScale : null,
|
landScale: typeof fixedOld?.landScale === 'number' ? fixedOld.landScale : null,
|
||||||
workload: typeof fixedOld?.workload === 'number' ? fixedOld.workload : null,
|
workload: typeof fixedOld?.workload === 'number' ? fixedOld.workload : null,
|
||||||
hourly: typeof fixedOld?.hourly === 'number' ? fixedOld.hourly : null,
|
hourly: typeof fixedOld?.hourly === 'number' ? fixedOld.hourly : null,
|
||||||
subtotal: null,
|
subtotal: typeof fixedOld?.subtotal === 'number' ? fixedOld.subtotal : null,
|
||||||
finalFee: null,
|
finalFee: typeof fixedOld?.finalFee === 'number' ? fixedOld.finalFee : null,
|
||||||
actions: null
|
actions: null
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -963,10 +901,13 @@ const applySelection = async (codes: string[]) => {
|
|||||||
await setCurrentContractState({
|
await setCurrentContractState({
|
||||||
...currentState,
|
...currentState,
|
||||||
selectedIds: uniqueIds,
|
selectedIds: uniqueIds,
|
||||||
detailRows: applyFixedRowTotals([...baseRows, fixedRow])
|
detailRows: applyFixedRowSummary([...baseRows, fixedRow])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务勾选变化入口:先更新行,再刷新新增服务的计价汇总。
|
||||||
|
*/
|
||||||
const handleServiceSelectionChange = async (ids: string[]) => {
|
const handleServiceSelectionChange = async (ids: string[]) => {
|
||||||
const prevIds = [...selectedIds.value]
|
const prevIds = [...selectedIds.value]
|
||||||
await applySelection(ids)
|
await applySelection(ids)
|
||||||
@ -977,133 +918,9 @@ const handleServiceSelectionChange = async (ids: string[]) => {
|
|||||||
await ensurePricingDetailRowsForCurrentSelection()
|
await ensurePricingDetailRowsForCurrentSelection()
|
||||||
}
|
}
|
||||||
|
|
||||||
const preparePickerOpen = () => {
|
/**
|
||||||
pickerTempIds.value = [...selectedIds.value]
|
* 页面初始化/激活时入口:加载合同、套用选择、确保明细、刷新合计。
|
||||||
pickerSearch.value = ''
|
*/
|
||||||
}
|
|
||||||
|
|
||||||
const closePicker = () => {
|
|
||||||
stopDragSelect()
|
|
||||||
pickerOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const handlePickerOpenChange = (open: boolean) => {
|
|
||||||
if (open) {
|
|
||||||
preparePickerOpen()
|
|
||||||
} else {
|
|
||||||
stopDragSelect()
|
|
||||||
}
|
|
||||||
pickerOpen.value = open
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmPicker = async () => {
|
|
||||||
await applySelection(pickerTempIds.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const clearPickerSelection = () => {
|
|
||||||
pickerTempIds.value = []
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const applyTempChecked = (code: string, checked: boolean) => {
|
|
||||||
const exists = pickerTempIds.value.includes(code)
|
|
||||||
if (checked && !exists) {
|
|
||||||
pickerTempIds.value = [...pickerTempIds.value, code]
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (!checked && exists) {
|
|
||||||
pickerTempIds.value = pickerTempIds.value.filter(item => item !== code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const setPickerItemRef = (
|
|
||||||
code: string,
|
|
||||||
el: Element | ComponentPublicInstance | null
|
|
||||||
) => {
|
|
||||||
if (el instanceof HTMLElement) {
|
|
||||||
pickerItemElMap.set(code, el)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pickerItemElMap.delete(code)
|
|
||||||
}
|
|
||||||
|
|
||||||
const isRectIntersect = (
|
|
||||||
a: { left: number; right: number; top: number; bottom: number },
|
|
||||||
b: { left: number; right: number; top: number; bottom: number }
|
|
||||||
) => !(a.right < b.left || a.left > b.right || a.bottom < b.top || a.top > b.bottom)
|
|
||||||
|
|
||||||
const applyDragSelectionByRect = () => {
|
|
||||||
const rect = {
|
|
||||||
left: Math.min(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
|
||||||
right: Math.max(dragStartPoint.value.x, dragCurrentPoint.value.x),
|
|
||||||
top: Math.min(dragStartPoint.value.y, dragCurrentPoint.value.y),
|
|
||||||
bottom: Math.max(dragStartPoint.value.y, dragCurrentPoint.value.y)
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextSelectedSet = new Set(dragBaseIds.value)
|
|
||||||
for (const [code, el] of pickerItemElMap.entries()) {
|
|
||||||
const itemRect = el.getBoundingClientRect()
|
|
||||||
const hit = isRectIntersect(rect, itemRect)
|
|
||||||
if (hit) {
|
|
||||||
dragTouchedIds.add(code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const code of dragTouchedIds) {
|
|
||||||
if (dragSelectChecked) {
|
|
||||||
nextSelectedSet.add(code)
|
|
||||||
} else {
|
|
||||||
nextSelectedSet.delete(code)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pickerTempIds.value = serviceDict
|
|
||||||
.value
|
|
||||||
.map(item => item.id)
|
|
||||||
.filter(id => nextSelectedSet.has(id))
|
|
||||||
}
|
|
||||||
|
|
||||||
const stopDragSelect = () => {
|
|
||||||
dragSelecting.value = false
|
|
||||||
dragMoved.value = false
|
|
||||||
dragBaseIds.value = []
|
|
||||||
dragTouchedIds.clear()
|
|
||||||
stopDragAutoScroll()
|
|
||||||
window.removeEventListener('mousemove', onDragSelectingMove)
|
|
||||||
window.removeEventListener('mouseup', stopDragSelect)
|
|
||||||
}
|
|
||||||
|
|
||||||
const onDragSelectingMove = (event: MouseEvent) => {
|
|
||||||
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
|
|
||||||
if (!dragMoved.value) {
|
|
||||||
const dx = Math.abs(event.clientX - dragStartPoint.value.x)
|
|
||||||
const dy = Math.abs(event.clientY - dragStartPoint.value.y)
|
|
||||||
if (dx >= 3 || dy >= 3) {
|
|
||||||
dragMoved.value = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
applyDragSelectionByRect()
|
|
||||||
}
|
|
||||||
|
|
||||||
const startDragSelect = (event: MouseEvent, code: string) => {
|
|
||||||
dragSelecting.value = true
|
|
||||||
dragMoved.value = false
|
|
||||||
dragBaseIds.value = [...pickerTempIds.value]
|
|
||||||
dragTouchedIds = new Set([code])
|
|
||||||
dragSelectChecked = !pickerTempIds.value.includes(code)
|
|
||||||
dragStartPoint.value = { x: event.clientX, y: event.clientY }
|
|
||||||
dragCurrentPoint.value = { x: event.clientX, y: event.clientY }
|
|
||||||
applyTempChecked(code, dragSelectChecked)
|
|
||||||
startDragAutoScroll()
|
|
||||||
window.addEventListener('mousemove', onDragSelectingMove)
|
|
||||||
window.addEventListener('mouseup', stopDragSelect)
|
|
||||||
}
|
|
||||||
const handleDragHover = (_code: string) => {
|
|
||||||
if (!dragSelecting.value || !dragMoved.value) return
|
|
||||||
applyDragSelectionByRect()
|
|
||||||
}
|
|
||||||
|
|
||||||
const initializeContractState = async () => {
|
const initializeContractState = async () => {
|
||||||
try {
|
try {
|
||||||
await zxFwPricingStore.loadContract(props.contractId)
|
await zxFwPricingStore.loadContract(props.contractId)
|
||||||
@ -1138,6 +955,7 @@ const initializeContractState = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 读取项目行业,用于过滤可选服务词典。 */
|
||||||
const loadProjectIndustry = async () => {
|
const loadProjectIndustry = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
|
const data = await kvStore.getItem<XmBaseInfoState>(PROJECT_INFO_KEY.value)
|
||||||
@ -1157,16 +975,21 @@ watch(serviceIdSignature, () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理表格单元格编辑:当前只接管 finalFee 列。
|
||||||
|
* 编辑后仅重算固定行,避免覆盖用户刚输入的确认金额。
|
||||||
|
*/
|
||||||
const handleCellValueChanged = async (event: any) => {
|
const handleCellValueChanged = async (event: any) => {
|
||||||
if (event.colDef?.field !== 'finalFee') return
|
if (event.colDef?.field !== 'finalFee') return
|
||||||
const row = event.data as DetailRow | undefined
|
const row = event.data as DetailRow | undefined
|
||||||
if (!row || isFixedRow(row)) return
|
if (!row || isFixedRow(row)) return
|
||||||
const newValue = event.newValue != null ? roundTo(Number(event.newValue), 2) : null
|
const newValue = event.newValue != null ? roundTo(Number(event.newValue), 2) : null
|
||||||
const currentState = getCurrentContractState()
|
const currentState = getCurrentContractState()
|
||||||
|
|
||||||
const nextRows = currentState.detailRows.map(item =>
|
const nextRows = currentState.detailRows.map(item =>
|
||||||
item.id === row.id ? { ...item, finalFee: newValue } : item
|
item.id === row.id ? { ...item, finalFee: newValue } : item
|
||||||
)
|
)
|
||||||
const finalRows = applyFixedRowTotals(nextRows)
|
const finalRows = applyFixedRowSummary(nextRows)
|
||||||
await setCurrentContractState({
|
await setCurrentContractState({
|
||||||
...currentState,
|
...currentState,
|
||||||
detailRows: finalRows
|
detailRows: finalRows
|
||||||
@ -1191,17 +1014,11 @@ onActivated(async () => {
|
|||||||
await loadProjectIndustry()
|
await loadProjectIndustry()
|
||||||
await initializeContractState()
|
await initializeContractState()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
stopDragSelect()
|
|
||||||
})
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div class="h-full min-h-0 flex flex-col gap-2">
|
<div class="h-full min-h-0 flex flex-col gap-2">
|
||||||
<!-- 原“浏览框选择服务”实现已抽离并停用,改为直接复选框勾选 -->
|
|
||||||
<!-- <DialogRoot v-model:open="pickerOpen" @update:open="handlePickerOpenChange" /> -->
|
|
||||||
<ServiceCheckboxSelector :services="serviceDict" :model-value="selectedIds"
|
<ServiceCheckboxSelector :services="serviceDict" :model-value="selectedIds"
|
||||||
@update:model-value="handleServiceSelectionChange" />
|
@update:model-value="handleServiceSelectionChange" />
|
||||||
|
|
||||||
|
|||||||
@ -624,27 +624,19 @@ onActivated(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const storageKeyRef = computed(() => props.storageKey)
|
const storageKeyRef = computed(() => props.storageKey)
|
||||||
const reserveAdditionalWorkKeyVersion = computed(() => {
|
const reserveAdditionalWorkStateSignature = computed(() => {
|
||||||
if (!isReserveStorageKey(props.storageKey)) return 0
|
if (!isReserveStorageKey(props.storageKey)) return ''
|
||||||
const contractId = String(props.contractId || '').trim()
|
const contractId = String(props.contractId || '').trim()
|
||||||
if (!contractId) return 0
|
if (!contractId) return ''
|
||||||
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
const additionalStorageKey = `htExtraFee-${contractId}-additional-work`
|
||||||
return zxFwPricingStore.getKeyVersion(additionalStorageKey)
|
return JSON.stringify(zxFwPricingStore.htFeeMainStates[additionalStorageKey] || null)
|
||||||
})
|
})
|
||||||
watch(storageKeyRef, () => {
|
watch(storageKeyRef, () => {
|
||||||
scheduleReloadFromStorage()
|
scheduleReloadFromStorage()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() =>
|
() => JSON.stringify(zxFwPricingStore.htFeeMethodStates[props.storageKey] || null),
|
||||||
detailRows.value
|
|
||||||
.map(row => {
|
|
||||||
const rateKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'rate-fee')
|
|
||||||
const hourlyKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'hourly-fee')
|
|
||||||
const quantityKey = zxFwPricingStore.getHtFeeMethodStorageKey(props.storageKey, row.id, 'quantity-unit-price-fee')
|
|
||||||
return `${row.id}:${zxFwPricingStore.getKeyVersion(rateKey)}:${zxFwPricingStore.getKeyVersion(hourlyKey)}:${zxFwPricingStore.getKeyVersion(quantityKey)}`
|
|
||||||
})
|
|
||||||
.join('|'),
|
|
||||||
(nextSignature, prevSignature) => {
|
(nextSignature, prevSignature) => {
|
||||||
if (!nextSignature && !prevSignature) return
|
if (!nextSignature && !prevSignature) return
|
||||||
if (nextSignature === prevSignature) return
|
if (nextSignature === prevSignature) return
|
||||||
@ -655,19 +647,19 @@ watch(
|
|||||||
watch(
|
watch(
|
||||||
() => {
|
() => {
|
||||||
const contractId = String(props.contractId || '').trim()
|
const contractId = String(props.contractId || '').trim()
|
||||||
if (!contractId) return 0
|
if (!contractId) return ''
|
||||||
return zxFwPricingStore.contractVersions[contractId] || 0
|
return JSON.stringify(zxFwPricingStore.contracts[contractId] || null)
|
||||||
},
|
},
|
||||||
(nextVersion, prevVersion) => {
|
(nextSig, prevSig) => {
|
||||||
if (nextVersion === prevVersion || nextVersion === 0) return
|
if (nextSig === prevSig) return
|
||||||
scheduleReloadFromStorage()
|
scheduleReloadFromStorage()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
reserveAdditionalWorkKeyVersion,
|
reserveAdditionalWorkStateSignature,
|
||||||
(nextVersion, prevVersion) => {
|
(nextSig, prevSig) => {
|
||||||
if (nextVersion === prevVersion) return
|
if (nextSig === prevSig) return
|
||||||
scheduleReloadFromStorage()
|
scheduleReloadFromStorage()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { addNumbers } from '@/lib/decimal'
|
import { addNumbers } from '@/lib/decimal'
|
||||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { waitForHydration } from '@/pinia/Plugin/indexdb'
|
||||||
import {
|
import {
|
||||||
parseHtFeeMainStorageKey,
|
parseHtFeeMainStorageKey,
|
||||||
parseHtFeeMethodStorageKey,
|
parseHtFeeMethodStorageKey,
|
||||||
@ -66,7 +66,6 @@ const STORAGE_PREFIX_METHOD_MAP = new Map<string, ServicePricingMethod>(
|
|||||||
|
|
||||||
const toKey = (contractId: string | number) => String(contractId || '').trim()
|
const toKey = (contractId: string | number) => String(contractId || '').trim()
|
||||||
const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim()
|
const toServiceKey = (serviceId: string | number) => String(serviceId || '').trim()
|
||||||
const dbKeyOf = (contractId: string) => `zxFW-${contractId}`
|
|
||||||
const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) =>
|
const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) =>
|
||||||
`${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}`
|
`${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}`
|
||||||
const round3 = (value: number) => Number(value.toFixed(3))
|
const round3 = (value: number) => Number(value.toFixed(3))
|
||||||
@ -139,7 +138,7 @@ const applyRowSubtotals = (rows: ZxFwDetailRow[]): ZxFwDetailRow[] => {
|
|||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
subtotal: round3Nullable(subtotal),
|
subtotal: round3Nullable(subtotal),
|
||||||
finalFee: round3Nullable(subtotal)
|
finalFee: row.finalFee != null ? round3Nullable(row.finalFee) : round3Nullable(subtotal)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -238,8 +237,9 @@ const parseServiceMethodStorageKey = (keyRaw: string | number) => {
|
|||||||
const loadTasks = new Map<string, Promise<ZxFwState | null>>()
|
const loadTasks = new Map<string, Promise<ZxFwState | null>>()
|
||||||
|
|
||||||
export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
||||||
|
let hydrationReady = false
|
||||||
|
let hydrationTask: Promise<void> | null = null
|
||||||
const contracts = ref<Record<string, ZxFwState>>({})
|
const contracts = ref<Record<string, ZxFwState>>({})
|
||||||
const contractVersions = ref<Record<string, number>>({})
|
|
||||||
const contractLoaded = ref<Record<string, boolean>>({})
|
const contractLoaded = ref<Record<string, boolean>>({})
|
||||||
const servicePricingStates = ref<Record<string, Record<string, ServicePricingState>>>({})
|
const servicePricingStates = ref<Record<string, Record<string, ServicePricingState>>>({})
|
||||||
|
|
||||||
@ -249,18 +249,18 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
const htFeeMainStates = htFeeStore.htFeeMainStates
|
const htFeeMainStates = htFeeStore.htFeeMainStates
|
||||||
const htFeeMethodStates = htFeeStore.htFeeMethodStates
|
const htFeeMethodStates = htFeeStore.htFeeMethodStates
|
||||||
const keyedStates = keysStore.keyedStates
|
const keyedStates = keysStore.keyedStates
|
||||||
const keyVersions = keysStore.keyVersions
|
|
||||||
|
|
||||||
const touchVersion = (contractId: string) => {
|
const ensureHydrated = async () => {
|
||||||
contractVersions.value[contractId] = (contractVersions.value[contractId] || 0) + 1
|
if (hydrationReady) return
|
||||||
}
|
if (!hydrationTask) {
|
||||||
|
hydrationTask = waitForHydration('zxFwPricing')
|
||||||
const getKvStoreSafely = () => {
|
.catch(() => undefined)
|
||||||
try {
|
.finally(() => {
|
||||||
return useKvStore()
|
hydrationReady = true
|
||||||
} catch {
|
hydrationTask = null
|
||||||
return null
|
})
|
||||||
}
|
}
|
||||||
|
await hydrationTask
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureServicePricingState = (contractIdRaw: string | number, serviceIdRaw: string | number) => {
|
const ensureServicePricingState = (contractIdRaw: string | number, serviceIdRaw: string | number) => {
|
||||||
@ -552,8 +552,6 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
return keysStore.removeKeyState(key)
|
return keysStore.removeKeyState(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getKeyVersion = (keyRaw: string | number) => keysStore.getKeyVersion(keyRaw)
|
|
||||||
|
|
||||||
// 对外返回合同咨询服务状态的深拷贝,避免组件直接改写 store 内部引用。
|
// 对外返回合同咨询服务状态的深拷贝,避免组件直接改写 store 内部引用。
|
||||||
const getContractState = (contractIdRaw: string | number) => {
|
const getContractState = (contractIdRaw: string | number) => {
|
||||||
const contractId = toKey(contractIdRaw)
|
const contractId = toKey(contractIdRaw)
|
||||||
@ -567,25 +565,15 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
const loadContract = async (contractIdRaw: string | number, force = false) => {
|
const loadContract = async (contractIdRaw: string | number, force = false) => {
|
||||||
const contractId = toKey(contractIdRaw)
|
const contractId = toKey(contractIdRaw)
|
||||||
if (!contractId) return null
|
if (!contractId) return null
|
||||||
|
await ensureHydrated()
|
||||||
if (!force && contractLoaded.value[contractId]) return getContractState(contractId)
|
if (!force && contractLoaded.value[contractId]) return getContractState(contractId)
|
||||||
if (!force && contracts.value[contractId]) return getContractState(contractId)
|
if (!force && contracts.value[contractId]) return getContractState(contractId)
|
||||||
if (!force && loadTasks.has(contractId)) return loadTasks.get(contractId) as Promise<ZxFwState | null>
|
if (!force && loadTasks.has(contractId)) return loadTasks.get(contractId) as Promise<ZxFwState | null>
|
||||||
|
|
||||||
const task = (async () => {
|
const task = (async () => {
|
||||||
const kvStore = getKvStoreSafely()
|
|
||||||
const raw = kvStore
|
|
||||||
? await kvStore.getItem<ZxFwState>(dbKeyOf(contractId))
|
|
||||||
: null
|
|
||||||
const current = contracts.value[contractId]
|
const current = contracts.value[contractId]
|
||||||
if (raw) {
|
if (!current) {
|
||||||
const normalized = normalizeState(raw)
|
|
||||||
if (!current || !isSameState(current, normalized)) {
|
|
||||||
contracts.value[contractId] = normalized
|
|
||||||
touchVersion(contractId)
|
|
||||||
}
|
|
||||||
} else if (!current) {
|
|
||||||
contracts.value[contractId] = normalizeState(null)
|
contracts.value[contractId] = normalizeState(null)
|
||||||
touchVersion(contractId)
|
|
||||||
}
|
}
|
||||||
contractLoaded.value[contractId] = true
|
contractLoaded.value[contractId] = true
|
||||||
return getContractState(contractId)
|
return getContractState(contractId)
|
||||||
@ -609,12 +597,13 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
if (current && isSameState(current, normalized)) return false
|
if (current && isSameState(current, normalized)) return false
|
||||||
contracts.value[contractId] = normalized
|
contracts.value[contractId] = normalized
|
||||||
contractLoaded.value[contractId] = true
|
contractLoaded.value[contractId] = true
|
||||||
touchVersion(contractId)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只更新某个服务行上的单个汇总字段,适合计费页回写 investScale/landScale/workload/hourly。
|
// 只更新某个服务行上的单个汇总字段,适合计费页回写 investScale/landScale/workload/hourly。
|
||||||
// 这里不会额外重算其它派生字段,调用方需要自行决定是否联动更新 subtotal/finalFee。
|
// 为保证“计价法金额变化 -> 确认金额跟随小计”,这里会同步重算 finalFee:
|
||||||
|
// - 普通行:finalFee = 当前四种计价法小计
|
||||||
|
// - 固定小计行:finalFee = 普通行 finalFee 合计
|
||||||
const updatePricingField = async (params: {
|
const updatePricingField = async (params: {
|
||||||
contractId: string
|
contractId: string
|
||||||
serviceId: string | number
|
serviceId: string | number
|
||||||
@ -632,7 +621,7 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
|
|
||||||
const nextValue = toFiniteNumberOrNull(params.value)
|
const nextValue = toFiniteNumberOrNull(params.value)
|
||||||
let changed = false
|
let changed = false
|
||||||
const nextRows = current.detailRows.map(row => {
|
const updatedRows = current.detailRows.map(row => {
|
||||||
if (String(row.id || '') !== targetServiceId) return row
|
if (String(row.id || '') !== targetServiceId) return row
|
||||||
const oldValue = toFiniteNumberOrNull(row[params.field])
|
const oldValue = toFiniteNumberOrNull(row[params.field])
|
||||||
if (oldValue === nextValue) return row
|
if (oldValue === nextValue) return row
|
||||||
@ -645,6 +634,37 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
|
|
||||||
if (!changed) return false
|
if (!changed) return false
|
||||||
|
|
||||||
|
const rowsWithSyncedFinalFee = updatedRows.map(row => {
|
||||||
|
if (String(row.id || '') === FIXED_ROW_ID) return row
|
||||||
|
const rowSubtotal = sumNullableNumbers([
|
||||||
|
toFiniteNumberOrNull(row.investScale),
|
||||||
|
toFiniteNumberOrNull(row.landScale),
|
||||||
|
toFiniteNumberOrNull(row.workload),
|
||||||
|
toFiniteNumberOrNull(row.hourly)
|
||||||
|
])
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
finalFee: round3Nullable(rowSubtotal)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fixedFinalFee = round3Nullable(
|
||||||
|
sumNullableNumbers(
|
||||||
|
rowsWithSyncedFinalFee
|
||||||
|
.filter(row => String(row.id || '') !== FIXED_ROW_ID)
|
||||||
|
.map(row => toFiniteNumberOrNull(row.finalFee))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextRows = rowsWithSyncedFinalFee.map(row =>
|
||||||
|
String(row.id || '') === FIXED_ROW_ID
|
||||||
|
? {
|
||||||
|
...row,
|
||||||
|
finalFee: fixedFinalFee
|
||||||
|
}
|
||||||
|
: row
|
||||||
|
)
|
||||||
|
|
||||||
const nextState = normalizeState({
|
const nextState = normalizeState({
|
||||||
...current,
|
...current,
|
||||||
detailRows: nextRows
|
detailRows: nextRows
|
||||||
@ -652,7 +672,6 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
if (isSameState(current, nextState)) return false
|
if (isSameState(current, nextState)) return false
|
||||||
contracts.value[contractId] = nextState
|
contracts.value[contractId] = nextState
|
||||||
contractLoaded.value[contractId] = true
|
contractLoaded.value[contractId] = true
|
||||||
touchVersion(contractId)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -693,10 +712,6 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
delete contractLoaded.value[contractId]
|
delete contractLoaded.value[contractId]
|
||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
if (Object.prototype.hasOwnProperty.call(contractVersions.value, contractId)) {
|
|
||||||
delete contractVersions.value[contractId]
|
|
||||||
changed = true
|
|
||||||
}
|
|
||||||
loadTasks.delete(contractId)
|
loadTasks.delete(contractId)
|
||||||
|
|
||||||
changed = htFeeStore.removeContractHtFeeData(contractId) || changed
|
changed = htFeeStore.removeContractHtFeeData(contractId) || changed
|
||||||
@ -708,20 +723,16 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
changed = keysStore.removeKeysByPrefix(`${prefix}-${contractId}-`) || changed
|
changed = keysStore.removeKeysByPrefix(`${prefix}-${contractId}-`) || changed
|
||||||
}
|
}
|
||||||
|
|
||||||
changed = keysStore.removeKeyState(dbKeyOf(contractId)) || changed
|
|
||||||
|
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contracts,
|
contracts,
|
||||||
contractVersions,
|
|
||||||
contractLoaded,
|
contractLoaded,
|
||||||
servicePricingStates,
|
servicePricingStates,
|
||||||
htFeeMainStates,
|
htFeeMainStates,
|
||||||
htFeeMethodStates,
|
htFeeMethodStates,
|
||||||
keyedStates,
|
keyedStates,
|
||||||
keyVersions,
|
|
||||||
getContractState,
|
getContractState,
|
||||||
loadContract,
|
loadContract,
|
||||||
setContractState,
|
setContractState,
|
||||||
@ -732,7 +743,6 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
|
|||||||
loadKeyState,
|
loadKeyState,
|
||||||
setKeyState,
|
setKeyState,
|
||||||
removeKeyState,
|
removeKeyState,
|
||||||
getKeyVersion,
|
|
||||||
getServicePricingMethodState,
|
getServicePricingMethodState,
|
||||||
setServicePricingMethodState,
|
setServicePricingMethodState,
|
||||||
loadServicePricingMethodState,
|
loadServicePricingMethodState,
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* 通用键值状态管理 Store
|
* 通用键值状态管理 Store
|
||||||
*
|
*
|
||||||
* 从 zxFwPricing 拆出,管理 IndexedDB 中的通用键值对状态,
|
* 从 zxFwPricing 拆出,管理 IndexedDB 中的通用键值对状态,
|
||||||
* 包括版本追踪和快照比对,用于避免不必要的写入。
|
* 通过快照比对避免不必要的写入。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
@ -31,22 +31,9 @@ export const useZxFwPricingKeysStore = defineStore('zxFwPricingKeys', () => {
|
|||||||
const keyedStates = ref<Record<string, unknown>>({})
|
const keyedStates = ref<Record<string, unknown>>({})
|
||||||
/** 键是否已从 IndexedDB 加载 */
|
/** 键是否已从 IndexedDB 加载 */
|
||||||
const keyedLoaded = ref<Record<string, boolean>>({})
|
const keyedLoaded = ref<Record<string, boolean>>({})
|
||||||
/** 键版本号(每次变更递增) */
|
|
||||||
const keyVersions = ref<Record<string, number>>({})
|
|
||||||
/** 键快照(用于比对是否变更) */
|
/** 键快照(用于比对是否变更) */
|
||||||
const keySnapshots = ref<Record<string, string>>({})
|
const keySnapshots = ref<Record<string, string>>({})
|
||||||
|
|
||||||
const touchKeyVersion = (key: string) => {
|
|
||||||
keyVersions.value[key] = (keyVersions.value[key] || 0) + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 获取指定键的版本号 */
|
|
||||||
const getKeyVersion = (keyRaw: string | number): number => {
|
|
||||||
const key = toKey(keyRaw)
|
|
||||||
if (!key) return 0
|
|
||||||
return keyVersions.value[key] || 0
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 按通用键获取状态 */
|
/** 按通用键获取状态 */
|
||||||
const getKeyState = <T = unknown>(keyRaw: string | number): T | null => {
|
const getKeyState = <T = unknown>(keyRaw: string | number): T | null => {
|
||||||
const key = toKey(keyRaw)
|
const key = toKey(keyRaw)
|
||||||
@ -80,7 +67,6 @@ export const useZxFwPricingKeysStore = defineStore('zxFwPricingKeys', () => {
|
|||||||
if (prevSnapshot !== nextSnapshot || !Object.prototype.hasOwnProperty.call(keyedStates.value, key)) {
|
if (prevSnapshot !== nextSnapshot || !Object.prototype.hasOwnProperty.call(keyedStates.value, key)) {
|
||||||
keyedStates.value[key] = cloneAny(raw)
|
keyedStates.value[key] = cloneAny(raw)
|
||||||
keySnapshots.value[key] = nextSnapshot
|
keySnapshots.value[key] = nextSnapshot
|
||||||
touchKeyVersion(key)
|
|
||||||
}
|
}
|
||||||
return getKeyState<T>(key)
|
return getKeyState<T>(key)
|
||||||
})()
|
})()
|
||||||
@ -108,7 +94,6 @@ export const useZxFwPricingKeysStore = defineStore('zxFwPricingKeys', () => {
|
|||||||
if (!force && prevSnapshot === nextSnapshot) return false
|
if (!force && prevSnapshot === nextSnapshot) return false
|
||||||
keyedStates.value[key] = cloneAny(value)
|
keyedStates.value[key] = cloneAny(value)
|
||||||
keySnapshots.value[key] = nextSnapshot
|
keySnapshots.value[key] = nextSnapshot
|
||||||
touchKeyVersion(key)
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,7 +105,6 @@ export const useZxFwPricingKeysStore = defineStore('zxFwPricingKeys', () => {
|
|||||||
delete keyedStates.value[key]
|
delete keyedStates.value[key]
|
||||||
keyedLoaded.value[key] = true
|
keyedLoaded.value[key] = true
|
||||||
keySnapshots.value[key] = toKeySnapshot(null)
|
keySnapshots.value[key] = toKeySnapshot(null)
|
||||||
touchKeyVersion(key)
|
|
||||||
return hadValue
|
return hadValue
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -130,7 +114,6 @@ export const useZxFwPricingKeysStore = defineStore('zxFwPricingKeys', () => {
|
|||||||
const allKeys = new Set<string>([
|
const allKeys = new Set<string>([
|
||||||
...Object.keys(keyedStates.value),
|
...Object.keys(keyedStates.value),
|
||||||
...Object.keys(keyedLoaded.value),
|
...Object.keys(keyedLoaded.value),
|
||||||
...Object.keys(keyVersions.value),
|
|
||||||
...Object.keys(keySnapshots.value)
|
...Object.keys(keySnapshots.value)
|
||||||
])
|
])
|
||||||
for (const key of allKeys) {
|
for (const key of allKeys) {
|
||||||
@ -140,7 +123,6 @@ export const useZxFwPricingKeysStore = defineStore('zxFwPricingKeys', () => {
|
|||||||
changed = true
|
changed = true
|
||||||
}
|
}
|
||||||
delete keyedLoaded.value[key]
|
delete keyedLoaded.value[key]
|
||||||
delete keyVersions.value[key]
|
|
||||||
delete keySnapshots.value[key]
|
delete keySnapshots.value[key]
|
||||||
keyLoadTasks.delete(key)
|
keyLoadTasks.delete(key)
|
||||||
}
|
}
|
||||||
@ -160,16 +142,13 @@ export const useZxFwPricingKeysStore = defineStore('zxFwPricingKeys', () => {
|
|||||||
return {
|
return {
|
||||||
keyedStates,
|
keyedStates,
|
||||||
keyedLoaded,
|
keyedLoaded,
|
||||||
keyVersions,
|
|
||||||
keySnapshots,
|
keySnapshots,
|
||||||
getKeyVersion,
|
|
||||||
getKeyState,
|
getKeyState,
|
||||||
loadKeyState,
|
loadKeyState,
|
||||||
setKeyState,
|
setKeyState,
|
||||||
removeKeyState,
|
removeKeyState,
|
||||||
removeKeysByPrefix,
|
removeKeysByPrefix,
|
||||||
setKeyStateSilent,
|
setKeyStateSilent
|
||||||
touchKeyVersion
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
persist: true
|
persist: true
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user