JGJS2026/src/components/pricing/WorkloadPricingPane.vue
2026-03-18 10:17:41 +08:00

563 lines
18 KiB
Vue

<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef } from 'ag-grid-community'
import { taskList } from '@/sql'
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
import { decimalAggSum, roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
import { formatThousands, formatThousandsFlexible } from '@/lib/numberFormat'
import { parseNumberOrNull } from '@/lib/number'
import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue'
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 zxFwPricingStore = useZxFwPricingStore()
const DB_KEY = computed(() => `gzlF-${props.contractId}-${props.serviceId}`)
const HT_CONSULT_FACTOR_KEY = computed(() => `ht-consult-category-factor-v1-${props.contractId}`)
const PRICING_CLEAR_SKIP_PREFIX = 'pricing-clear-skip:'
const PRICING_FORCE_DEFAULT_PREFIX = 'pricing-force-default:'
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(HT_CONSULT_FACTOR_KEY.value)
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 getMethodState = () =>
zxFwPricingStore.getServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
const detailRows = computed<DetailRow[]>({
get: () => {
const rows = getMethodState()?.detailRows
return Array.isArray(rows) ? rows : []
},
set: rows => {
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'workload', {
detailRows: rows
})
}
})
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, precision: 3 })
const parseSanitizedAdoptedPriceOrNull = (value: unknown) =>
parseNumberOrNull(value, { sanitize: true, precision: 6 })
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 formatThousandsFlexible(params.value, 3)
}
const spanRowsByTaskName = (params: any) => {
const rowA = params?.nodeA?.data as DetailRow | undefined
const rowB = params?.nodeB?.data as DetailRow | undefined
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 => parseSanitizedAdoptedPriceOrNull(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 `${formatThousandsFlexible(params.value, 6)}${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 formatThousandsFlexible(roundTo(params.value, 3), 3)
}
},
{
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 ? ' 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 sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
if (!Number.isFinite(value)) return null
hasValid = true
return value
})
return hasValid ? total : null
}
const totalServiceFee = computed(() => sumNullableBy(detailRows.value.filter(e => e.basicFee !== null && e.basicFee !== undefined), 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()))
}
zxFwPricingStore.setServicePricingMethodState(props.contractId, props.serviceId, 'workload', payload)
const synced = await syncPricingTotalToZxFw({
contractId: props.contractId,
serviceId: props.serviceId,
field: 'workload',
value: totalServiceFee.value
})
if (!synced) return
} 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 zxFwPricingStore.loadServicePricingMethodState<DetailRow>(props.contractId, props.serviceId, 'workload')
if (data) {
detailRows.value = mergeWithDictRows(data.detailRows)
return
}
detailRows.value = buildDefaultRows()
} catch (error) {
console.error('loadFromIndexedDB failed:', error)
detailRows.value = buildDefaultRows()
}
}
const handleCellValueChanged = () => {
void saveToIndexedDB()
}
onMounted(async () => {
await loadFromIndexedDB()
})
onBeforeUnmount(() => {
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="agGridWrapClass">
<AgGridVue :style="agGridStyle" :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>
<MethodUnavailableNotice
v-else
title="该服务不适用工作量法"
message="当前服务没有关联工作量法任务,无需填写此部分内容。"
/>
</div>
</div>
</template>