563 lines
18 KiB
Vue
563 lines
18 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||
import { AgGridVue } from 'ag-grid-vue3'
|
||
import type { ColDef } from 'ag-grid-community'
|
||
import localforage from 'localforage'
|
||
import { taskList } from '@/sql'
|
||
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
|
||
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||
import { formatThousands } from '@/lib/numberFormat'
|
||
import { parseNumberOrNull } from '@/lib/number'
|
||
import { syncPricingTotalToZxFw, ZXFW_RELOAD_SERVICE_KEY } from '@/lib/zxFwPricingSync'
|
||
import { usePricingPaneReloadStore } from '@/pinia/pricingPaneReload'
|
||
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
|
||
|
||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||
|
||
interface DetailRow {
|
||
id: string
|
||
taskCode: string
|
||
taskName: string
|
||
unit: string
|
||
conversion: number | null
|
||
workload: number | null
|
||
basicFee: number | null
|
||
budgetBase: string
|
||
budgetReferenceUnitPrice: string
|
||
budgetAdoptedUnitPrice: number | null
|
||
consultCategoryFactor: number | null
|
||
serviceFee: number | null
|
||
remark: string
|
||
path: string[]
|
||
}
|
||
|
||
interface XmInfoState {
|
||
projectName: string
|
||
detailRows: DetailRow[]
|
||
}
|
||
|
||
const props = defineProps<{
|
||
contractId: string,
|
||
|
||
serviceId: string | number
|
||
}>()
|
||
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
|
||
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
|
||
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
|
||
const pricingPaneReloadStore = usePricingPaneReloadStore()
|
||
const consultCategoryFactorMap = ref<Map<string, number | null>>(new Map())
|
||
let factorDefaultsLoaded = false
|
||
const paneInstanceCreatedAt = Date.now()
|
||
|
||
const getDefaultConsultCategoryFactor = () =>
|
||
consultCategoryFactorMap.value.get(String(props.serviceId)) ?? null
|
||
|
||
const ensureFactorDefaultsLoaded = async () => {
|
||
if (factorDefaultsLoaded) return
|
||
consultCategoryFactorMap.value = await loadConsultCategoryFactorMap()
|
||
factorDefaultsLoaded = true
|
||
}
|
||
|
||
const shouldSkipPersist = () => {
|
||
const storageKey = `${PRICING_CLEAR_SKIP_PREFIX}${DB_KEY.value}`
|
||
const raw = sessionStorage.getItem(storageKey)
|
||
if (!raw) return false
|
||
const now = Date.now()
|
||
|
||
if (raw.includes(':')) {
|
||
const [issuedRaw, untilRaw] = raw.split(':')
|
||
const issuedAt = Number(issuedRaw)
|
||
const skipUntil = Number(untilRaw)
|
||
if (Number.isFinite(issuedAt) && Number.isFinite(skipUntil) && now <= skipUntil) {
|
||
return paneInstanceCreatedAt <= issuedAt
|
||
}
|
||
sessionStorage.removeItem(storageKey)
|
||
return false
|
||
}
|
||
|
||
const skipUntil = Number(raw)
|
||
if (Number.isFinite(skipUntil) && now <= skipUntil) return true
|
||
sessionStorage.removeItem(storageKey)
|
||
return false
|
||
}
|
||
|
||
const shouldForceDefaultLoad = () => {
|
||
const storageKey = `${PRICING_FORCE_DEFAULT_PREFIX}${DB_KEY.value}`
|
||
const raw = sessionStorage.getItem(storageKey)
|
||
if (!raw) return false
|
||
const forceUntil = Number(raw)
|
||
sessionStorage.removeItem(storageKey)
|
||
return Number.isFinite(forceUntil) && Date.now() <= forceUntil
|
||
}
|
||
|
||
const detailRows = ref<DetailRow[]>([])
|
||
|
||
type taskLite = {
|
||
serviceID: number
|
||
code?: string
|
||
ref?: string
|
||
name: string
|
||
basicParam: string
|
||
unit: string
|
||
conversion: number | null
|
||
maxPrice: number | null
|
||
minPrice: number | null
|
||
defPrice: number | null
|
||
desc: string | null
|
||
}
|
||
|
||
const formatTaskReferenceUnitPrice = (task: taskLite) => {
|
||
const unit = task.unit || ''
|
||
const hasMin = typeof task.minPrice === 'number' && Number.isFinite(task.minPrice)
|
||
const hasMax = typeof task.maxPrice === 'number' && Number.isFinite(task.maxPrice)
|
||
if (hasMin && hasMax) return `${task.minPrice}${unit}-${task.maxPrice}${unit}`
|
||
if (hasMin) return `${task.minPrice}${unit}`
|
||
if (hasMax) return `${task.maxPrice}${unit}`
|
||
return ''
|
||
}
|
||
|
||
const getSourceTaskIds = () => {
|
||
const currentServiceId = Number(props.serviceId)
|
||
return Object.entries(taskList as Record<string, taskLite>)
|
||
.filter(([, task]) => Number(task.serviceID) === currentServiceId)
|
||
.map(([key]) => Number(key))
|
||
.filter(Number.isFinite)
|
||
.sort((a, b) => a - b)
|
||
}
|
||
|
||
const isWorkloadMethodApplicable = computed(() => getSourceTaskIds().length > 0)
|
||
|
||
const buildDefaultRows = (): DetailRow[] => {
|
||
const rows: DetailRow[] = []
|
||
const sourceTaskIds = getSourceTaskIds()
|
||
|
||
for (const [order, taskId] of sourceTaskIds.entries()) {
|
||
const task = (taskList as Record<string, taskLite | undefined>)[String(taskId)]
|
||
const taskCode = task?.code || task?.ref || ''
|
||
if (!taskCode || !task?.name) continue
|
||
const rowId = `task-${taskId}-${order}`
|
||
rows.push({
|
||
id: rowId,
|
||
taskCode,
|
||
taskName: task.name,
|
||
unit: task.unit || '',
|
||
conversion: typeof task.conversion === 'number' && Number.isFinite(task.conversion) ? task.conversion : null,
|
||
workload: null,
|
||
basicFee: null,
|
||
budgetBase: task.basicParam || '',
|
||
budgetReferenceUnitPrice: formatTaskReferenceUnitPrice(task),
|
||
budgetAdoptedUnitPrice:
|
||
typeof task.defPrice === 'number' && Number.isFinite(task.defPrice) ? task.defPrice : null,
|
||
consultCategoryFactor: getDefaultConsultCategoryFactor(),
|
||
serviceFee: null,
|
||
remark: task.desc|| '',
|
||
path: [rowId]
|
||
})
|
||
}
|
||
|
||
return rows
|
||
}
|
||
|
||
const isNoTaskRow = (row: DetailRow | undefined) => row?.id?.startsWith('task-none-') ?? false
|
||
|
||
const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] => {
|
||
const dbValueMap = new Map<string, DetailRow>()
|
||
for (const row of rowsFromDb || []) {
|
||
dbValueMap.set(row.id, row)
|
||
}
|
||
|
||
return buildDefaultRows().map(row => {
|
||
const fromDb = dbValueMap.get(row.id)
|
||
if (!fromDb) return row
|
||
|
||
return {
|
||
...row,
|
||
workload: typeof fromDb.workload === 'number' ? fromDb.workload : null,
|
||
basicFee: typeof fromDb.basicFee === 'number' ? fromDb.basicFee : null,
|
||
budgetAdoptedUnitPrice:
|
||
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
|
||
consultCategoryFactor:
|
||
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : null,
|
||
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : null,
|
||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : ''
|
||
}
|
||
})
|
||
}
|
||
|
||
const parseSanitizedNumberOrNull = (value: unknown) =>
|
||
parseNumberOrNull(value, { sanitize: true })
|
||
|
||
const calcBasicFee = (row: DetailRow | undefined) => {
|
||
if (!row || isNoTaskRow(row)) return null
|
||
const price = row.budgetAdoptedUnitPrice
|
||
const conversion = row.conversion
|
||
const workload = row.workload
|
||
if (
|
||
typeof price !== 'number' ||
|
||
!Number.isFinite(price) ||
|
||
typeof conversion !== 'number' ||
|
||
!Number.isFinite(conversion) ||
|
||
typeof workload !== 'number' ||
|
||
!Number.isFinite(workload)
|
||
) {
|
||
return null
|
||
}
|
||
return roundTo(toDecimal(price).mul(conversion).mul(workload), 2)
|
||
}
|
||
|
||
const calcServiceFee = (row: DetailRow | undefined) => {
|
||
if (!row || isNoTaskRow(row)) return null
|
||
const factor = row.consultCategoryFactor
|
||
const basicFee = calcBasicFee(row)
|
||
if (
|
||
basicFee == null ||
|
||
typeof factor !== 'number' ||
|
||
!Number.isFinite(factor)
|
||
) {
|
||
return null
|
||
}
|
||
return roundTo(toDecimal(basicFee).mul(factor), 2)
|
||
}
|
||
|
||
const formatEditableNumber = (params: any) => {
|
||
if (isNoTaskRow(params.data)) return '无'
|
||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||
return '点击输入'
|
||
}
|
||
if (params.value == null) return ''
|
||
return Number(params.value).toFixed(2)
|
||
}
|
||
|
||
const spanRowsByTaskName = (params: any) => {
|
||
const rowA = params?.nodeA?.data as DetailRow | undefined
|
||
const rowB = params?.nodeB?.data as DetailRow | undefined
|
||
// debugger
|
||
if (!rowA || !rowB) return false
|
||
if (isNoTaskRow(rowA) || isNoTaskRow(rowB)) return false
|
||
|
||
return Boolean(rowA.taskName) && Boolean(rowA.budgetBase) && rowA.taskName === rowB.taskName && rowA.budgetBase === rowB.budgetBase
|
||
}
|
||
|
||
const columnDefs: ColDef<DetailRow>[] = [
|
||
{
|
||
headerName: '编码',
|
||
field: 'taskCode',
|
||
minWidth: 100,
|
||
width: 120,
|
||
pinned: 'left',
|
||
colSpan: params => (params.node?.rowPinned ? 3 : 1),
|
||
valueFormatter: params => (params.node?.rowPinned ? '总合计' : params.value || '')
|
||
},
|
||
{
|
||
headerName: '名称',
|
||
field: 'taskName',
|
||
minWidth: 150,
|
||
width: 220,
|
||
pinned: 'left',
|
||
autoHeight: true,
|
||
|
||
spanRows: true,
|
||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||
},
|
||
{
|
||
headerName: '预算基数',
|
||
field: 'budgetBase',
|
||
minWidth: 150,
|
||
autoHeight: true,
|
||
|
||
width: 180,
|
||
pinned: 'left',
|
||
spanRows: spanRowsByTaskName,
|
||
valueFormatter: params => (params.node?.rowPinned ? '' : params.value || '')
|
||
},
|
||
{
|
||
headerName: '预算参考单价',
|
||
field: 'budgetReferenceUnitPrice',
|
||
minWidth: 170,
|
||
flex: 1,
|
||
valueFormatter: params => params.value || ''
|
||
},
|
||
{
|
||
headerName: '预算采用单价',
|
||
field: 'budgetAdoptedUnitPrice',
|
||
headerClass: 'ag-right-aligned-header',
|
||
minWidth: 170,
|
||
flex: 1,
|
||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'ag-right-aligned-cell editable-cell-line' : 'ag-right-aligned-cell'),
|
||
cellClassRules: {
|
||
'editable-cell-empty': params =>
|
||
!params.node?.group &&
|
||
!params.node?.rowPinned &&
|
||
!isNoTaskRow(params.data) &&
|
||
(params.value == null || params.value === '')
|
||
},
|
||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
||
valueFormatter: params => {
|
||
if (isNoTaskRow(params.data)) return '无'
|
||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||
return '点击输入'
|
||
}
|
||
if (params.value == null) return ''
|
||
const unit = params.data?.unit || ''
|
||
return `${formatThousands(params.value)}${unit}`
|
||
}
|
||
},
|
||
{
|
||
headerName: '工作量',
|
||
field: 'workload',
|
||
minWidth: 140,
|
||
flex: 1,
|
||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||
cellClassRules: {
|
||
'editable-cell-empty': params =>
|
||
!params.node?.group &&
|
||
!params.node?.rowPinned &&
|
||
!isNoTaskRow(params.data) &&
|
||
(params.value == null || params.value === '')
|
||
},
|
||
aggFunc: decimalAggSum,
|
||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
||
valueFormatter: formatEditableNumber
|
||
},
|
||
{
|
||
headerName: '咨询分类系数',
|
||
field: 'consultCategoryFactor',
|
||
width: 80,
|
||
minWidth: 70,
|
||
maxWidth: 90,
|
||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||
cellClassRules: {
|
||
'editable-cell-empty': params =>
|
||
!params.node?.group &&
|
||
!params.node?.rowPinned &&
|
||
!isNoTaskRow(params.data) &&
|
||
(params.value == null || params.value === '')
|
||
},
|
||
valueParser: params => parseSanitizedNumberOrNull(params.newValue),
|
||
valueFormatter: formatEditableNumber
|
||
},
|
||
{
|
||
headerName: '服务费用(元)',
|
||
field: 'serviceFee',
|
||
headerClass: 'ag-right-aligned-header',
|
||
minWidth: 150,
|
||
flex: 1,
|
||
cellClass: 'ag-right-aligned-cell',
|
||
editable: false,
|
||
valueGetter: params => (params.node?.rowPinned ? params.data?.serviceFee ?? null : calcServiceFee(params.data)),
|
||
aggFunc: decimalAggSum,
|
||
valueFormatter: params => {
|
||
if (isNoTaskRow(params.data)) return '无'
|
||
if (params.value == null || params.value === '') return ''
|
||
return formatThousands(roundTo(params.value, 2))
|
||
}
|
||
},
|
||
{
|
||
headerName: '说明',
|
||
field: 'remark',
|
||
minWidth: 180,
|
||
flex: 1.2,
|
||
cellEditor: 'agLargeTextCellEditor',
|
||
wrapText: true,
|
||
autoHeight: true,
|
||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||
|
||
editable: false,
|
||
valueFormatter: params => {
|
||
|
||
return params.value || ''
|
||
},
|
||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line remark-wrap-cell' : ''),
|
||
cellClassRules: {
|
||
'editable-cell-empty': params =>
|
||
!params.node?.group &&
|
||
!params.node?.rowPinned &&
|
||
!isNoTaskRow(params.data) &&
|
||
(params.value == null || params.value === '')
|
||
}
|
||
}
|
||
]
|
||
|
||
const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
|
||
const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row)))
|
||
|
||
const totalServiceFee = computed(() => sumByNumber(detailRows.value, row => calcServiceFee(row)))
|
||
const pinnedTopRowData = computed(() => [
|
||
{
|
||
id: 'pinned-total-row',
|
||
taskCode: '总合计',
|
||
taskName: '',
|
||
unit: '',
|
||
conversion: null,
|
||
workload: totalWorkload.value,
|
||
basicFee: totalBasicFee.value,
|
||
budgetBase: '',
|
||
budgetReferenceUnitPrice: '',
|
||
budgetAdoptedUnitPrice: null,
|
||
consultCategoryFactor: null,
|
||
serviceFee: totalServiceFee.value,
|
||
remark: '',
|
||
path: ['TOTAL']
|
||
}
|
||
])
|
||
|
||
|
||
|
||
const buildPersistDetailRows = () =>
|
||
detailRows.value.map(row => ({
|
||
...row,
|
||
basicFee: calcBasicFee(row),
|
||
serviceFee: calcServiceFee(row)
|
||
}))
|
||
const saveToIndexedDB = async () => {
|
||
if (!isWorkloadMethodApplicable.value) return
|
||
if (shouldSkipPersist()) return
|
||
try {
|
||
const payload = {
|
||
detailRows: JSON.parse(JSON.stringify(buildPersistDetailRows()))
|
||
}
|
||
console.log('Saving to IndexedDB:', 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) {
|
||
console.error('saveToIndexedDB failed:', error)
|
||
}
|
||
}
|
||
|
||
const loadFromIndexedDB = async () => {
|
||
try {
|
||
if (!isWorkloadMethodApplicable.value) {
|
||
detailRows.value = []
|
||
return
|
||
}
|
||
|
||
await ensureFactorDefaultsLoaded()
|
||
|
||
if (shouldForceDefaultLoad()) {
|
||
detailRows.value = buildDefaultRows()
|
||
return
|
||
}
|
||
|
||
const data = await localforage.getItem<XmInfoState>(DB_KEY.value)
|
||
if (data) {
|
||
detailRows.value = mergeWithDictRows(data.detailRows)
|
||
return
|
||
}
|
||
|
||
detailRows.value = buildDefaultRows()
|
||
} catch (error) {
|
||
console.error('loadFromIndexedDB failed:', error)
|
||
detailRows.value = buildDefaultRows()
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => pricingPaneReloadStore.getReloadVersion(props.contractId, props.serviceId),
|
||
(nextVersion, prevVersion) => {
|
||
if (nextVersion === prevVersion || nextVersion === 0) return
|
||
void loadFromIndexedDB()
|
||
}
|
||
)
|
||
|
||
let persistTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
|
||
|
||
|
||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||
const handleCellValueChanged = () => {
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
gridPersistTimer = setTimeout(() => {
|
||
void saveToIndexedDB()
|
||
}, 1000)
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadFromIndexedDB()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
if (persistTimer) clearTimeout(persistTimer)
|
||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||
void saveToIndexedDB()
|
||
})
|
||
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 (e) {
|
||
// 解析失败时返回原始值,无需额外处理
|
||
}
|
||
return params.value;
|
||
};
|
||
|
||
const mydiyTheme = myTheme.withParams({
|
||
rowBorder: {
|
||
style: "solid",
|
||
width: 0.8,
|
||
color: "#d8d8dd"
|
||
},
|
||
columnBorder: {
|
||
style: "solid",
|
||
width: 0.8,
|
||
color: "#d8d8dd"
|
||
}
|
||
})
|
||
</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">工作量明细</h3>
|
||
<div class="text-xs text-muted-foreground"></div>
|
||
</div>
|
||
|
||
<div v-if="isWorkloadMethodApplicable" class="ag-theme-quartz h-full min-h-0 w-full flex-1">
|
||
<AgGridVue :style="{ height: '100%' }" :rowData="detailRows" :pinnedTopRowData="pinnedTopRowData"
|
||
:columnDefs="columnDefs" :gridOptions="gridOptions" :theme="mydiyTheme" :treeData="false"
|
||
:enableCellSpan="true"
|
||
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
||
:suppressRowVirtualisation="true"
|
||
|
||
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||
|
||
|
||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50"
|
||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||
</div>
|
||
<div
|
||
v-else
|
||
class="flex h-full min-h-0 w-full flex-1 items-center justify-center bg-[radial-gradient(circle_at_top,_rgba(220,38,38,0.18),rgba(0,0,0,0.03)_46%,transparent_72%)] p-6"
|
||
>
|
||
<div class="w-full max-w-xl rounded-2xl border border-red-300/85 bg-white/90 px-8 py-10 text-center shadow-[0_18px_38px_-22px_rgba(153,27,27,0.6)] backdrop-blur">
|
||
<p class="text-lg font-semibold tracking-wide text-neutral-900">该服务不适用工作量法</p>
|
||
<p class="mt-2 text-sm leading-6 text-red-700">当前服务没有关联工作量法任务,无需填写此部分内容。</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|