优化
This commit is contained in:
parent
8417f8d5cc
commit
693a9628bc
@ -201,13 +201,13 @@ let data1 = {
|
||||
},
|
||||
],
|
||||
addtional: {// 附加工作费
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
|
||||
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'C' }] },
|
||||
name: '附加工作',
|
||||
fee: 10000,
|
||||
det: [
|
||||
{
|
||||
id: 0,
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
|
||||
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'F' }] },
|
||||
name: '人员驻场服务及其他附加工作',
|
||||
fee: 10000,
|
||||
m4: {
|
||||
@ -258,7 +258,7 @@ let data1 = {
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' }] },
|
||||
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'C' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'X' }] },
|
||||
name: '咨询服务协调工作',
|
||||
fee: 10000,
|
||||
m0: {
|
||||
@ -314,7 +314,7 @@ let data1 = {
|
||||
]
|
||||
},
|
||||
reserve: {// 预备费
|
||||
ref: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }] },
|
||||
code: { richText: [{ font: { charset: 134, color: { theme: 1 }, italic: true, name: '宋体', size: 10 }, text: 'Y' }, { font: { charset: 134, color: { theme: 1 }, italic: true, name: 'Calibri', size: 10, vertAlign: 'subscript' }, text: 'B' }] },
|
||||
name: '预备费',
|
||||
fee: 10000,
|
||||
tasks:[],
|
||||
|
||||
@ -12,6 +12,29 @@ import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
|
||||
import {
|
||||
cloneJson,
|
||||
CONTRACT_CONSULT_FACTOR_KEY_PREFIX,
|
||||
CONTRACT_KEY_PREFIX,
|
||||
CONTRACT_MAJOR_FACTOR_KEY_PREFIX,
|
||||
CONTRACT_SEGMENT_FILE_EXTENSION,
|
||||
CONTRACT_SEGMENT_VERSION,
|
||||
formatExportTimestamp,
|
||||
generateContractId,
|
||||
isContractRelatedForageKey,
|
||||
isContractRelatedKeyedStateKey,
|
||||
isContractSegmentPackage,
|
||||
isRecord,
|
||||
normalizeContractSegmentPackage,
|
||||
PROJECT_INFO_KEY,
|
||||
PROJECT_SCALE_KEY,
|
||||
PRICING_KEY_PREFIXES,
|
||||
rewriteKeyWithContractId,
|
||||
SERVICE_KEY_PREFIX,
|
||||
SERVICE_PRICING_METHODS,
|
||||
type ContractSegmentPackage,
|
||||
type DataEntry
|
||||
} from '@/lib/contractSegment'
|
||||
import { industryTypeList } from '@/sql'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { formatThousands } from '@/lib/numberFormat'
|
||||
@ -39,43 +62,6 @@ interface ContractItem {
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface DataEntry {
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
|
||||
interface ContractSegmentPackage {
|
||||
version: number
|
||||
exportedAt: string
|
||||
packageType?: 'contract-segments'
|
||||
project?: {
|
||||
industry: string
|
||||
}
|
||||
storage?: {
|
||||
localforageEntries: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
}
|
||||
contracts: ContractItem[]
|
||||
projectIndustry?: string
|
||||
localforageEntries?: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
pinia?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
servicePricingStates?: Record<string, unknown>
|
||||
htFeeMainStates?: Record<string, unknown>
|
||||
htFeeMethodStates?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
piniaState?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
servicePricingStates?: Record<string, unknown>
|
||||
htFeeMainStates?: Record<string, unknown>
|
||||
htFeeMethodStates?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
interface XmBaseInfoState {
|
||||
projectIndustry?: string
|
||||
}
|
||||
@ -117,18 +103,6 @@ interface QuantityMethodStateLike {
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'ht-card-v1'
|
||||
const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
|
||||
const CONTRACT_SEGMENT_VERSION = 3
|
||||
const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
|
||||
const SERVICE_KEY_PREFIX = 'zxFW-'
|
||||
const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
|
||||
const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
|
||||
const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-']
|
||||
const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
const PROJECT_SCALE_KEY = 'xm-info-v3'
|
||||
const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const
|
||||
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
|
||||
@ -431,15 +405,6 @@ const scheduleRefreshContractBudgets = () => {
|
||||
}, 80)
|
||||
}
|
||||
|
||||
const formatExportTimestamp = (date: Date): string => {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}`
|
||||
}
|
||||
|
||||
const industryNameByCode = (() => {
|
||||
const map = new Map<string, string>()
|
||||
for (const item of industryTypeList) {
|
||||
@ -623,31 +588,6 @@ const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
|
||||
})
|
||||
}
|
||||
|
||||
const normalizeDataEntries = (value: unknown): DataEntry[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter(item => item && typeof item === 'object' && typeof (item as DataEntry).key === 'string')
|
||||
.map(item => ({
|
||||
key: String((item as DataEntry).key),
|
||||
value: (item as DataEntry).value
|
||||
}))
|
||||
}
|
||||
|
||||
const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
|
||||
projectIndustry:
|
||||
typeof payload.project?.industry === 'string' && payload.project.industry.trim()
|
||||
? payload.project.industry.trim()
|
||||
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
||||
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
||||
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
|
||||
piniaState: payload.pinia ?? payload.piniaState
|
||||
})
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
|
||||
const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
|
||||
const buildContractPiniaPayload = async (contractIds: string[]) => {
|
||||
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
|
||||
const payload = {
|
||||
@ -749,27 +689,6 @@ const applyImportedContractPiniaPayload = async (
|
||||
}
|
||||
}
|
||||
|
||||
const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
|
||||
const payload = value as Partial<ContractSegmentPackage> | null
|
||||
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
|
||||
}
|
||||
|
||||
const isContractRelatedForageKey = (key: string, contractId: string) => {
|
||||
if (key === `${CONTRACT_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${SERVICE_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${contractId}`) return true
|
||||
if (PRICING_KEY_PREFIXES.some(prefix => key.startsWith(`${prefix}${contractId}-`))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const isContractRelatedKeyedStateKey = (key: string, contractId: string) => {
|
||||
if (key === `ht-base-info-${contractId}`) return true
|
||||
if (key.startsWith(`work-content-${contractId}-`)) return true
|
||||
if (key.startsWith(`work-content-htExtraFee-${contractId}-`)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
const readContractRelatedForageEntries = async (contractIds: string[]) => {
|
||||
const keys = await kvStore.keys()
|
||||
const idSet = new Set(contractIds)
|
||||
@ -802,39 +721,6 @@ const readContractRelatedKeyedEntries = (contractIds: string[]) => {
|
||||
}))
|
||||
}
|
||||
|
||||
const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
|
||||
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
|
||||
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
|
||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${fromId}`) {
|
||||
return `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${toId}`
|
||||
}
|
||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
|
||||
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
|
||||
}
|
||||
if (key === `ht-base-info-${fromId}`) return `ht-base-info-${toId}`
|
||||
if (key.startsWith(`work-content-${fromId}-`)) {
|
||||
return key.replace(`work-content-${fromId}-`, `work-content-${toId}-`)
|
||||
}
|
||||
if (key.startsWith(`work-content-htExtraFee-${fromId}-`)) {
|
||||
return key.replace(`work-content-htExtraFee-${fromId}-`, `work-content-htExtraFee-${toId}-`)
|
||||
}
|
||||
for (const prefix of PRICING_KEY_PREFIXES) {
|
||||
if (key.startsWith(`${prefix}${fromId}-`)) {
|
||||
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
const generateContractId = (usedIds: Set<string>) => {
|
||||
let nextId = ''
|
||||
while (!nextId || usedIds.has(nextId)) {
|
||||
nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
}
|
||||
usedIds.add(nextId)
|
||||
return nextId
|
||||
}
|
||||
|
||||
const exportSelectedContracts = async () => {
|
||||
if (selectedContractIds.value.length === 0) {
|
||||
window.alert('请先勾选至少一个合同段。')
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineComponent, h, nextTick, onActivated, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
|
||||
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, shallowRef, watch, type PropType } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, FirstDataRenderedEvent, GridApi, GridOptions, GridReadyEvent, ICellRendererParams, RowDataUpdatedEvent } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
@ -422,11 +422,14 @@ const onGridReady = (event: GridReadyEvent<SummaryRow>) => {
|
||||
void syncAutoRowHeights()
|
||||
}
|
||||
|
||||
const isGridApiAlive = (api: GridApi<SummaryRow> | null | undefined): api is GridApi<SummaryRow> =>
|
||||
Boolean(api && !api.isDestroyed?.())
|
||||
|
||||
const syncAutoRowHeights = async () => {
|
||||
await nextTick()
|
||||
const api = gridApi.value
|
||||
if (!api) return
|
||||
api.resetRowHeights()
|
||||
if (!isGridApiAlive(api)) return
|
||||
api.onRowHeightChanged()
|
||||
api.refreshCells({ force: true })
|
||||
}
|
||||
|
||||
@ -457,6 +460,13 @@ onMounted(() => {
|
||||
onActivated(() => {
|
||||
void reloadRows()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (isGridApiAlive(gridApi.value)) {
|
||||
gridApi.value.stopEditing()
|
||||
}
|
||||
gridApi.value = null
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@ -212,7 +212,8 @@ onBeforeUnmount(() => {
|
||||
<div class="text-xs text-muted-foreground">费率(%)</div>
|
||||
<input v-model="rateInput" type="text" inputmode="decimal" placeholder="请输入费率,建议1 ~ 5"
|
||||
class="rate-input h-9 w-full rounded-md border bg-background px-3 text-sm text-foreground outline-none focus:ring-2 focus:ring-primary/30"
|
||||
@blur="applyRateInput" />
|
||||
@blur="applyRateInput"
|
||||
@keydown.enter.prevent="applyRateInput" />
|
||||
</label>
|
||||
|
||||
<label class="space-y-1.5">
|
||||
|
||||
@ -202,11 +202,13 @@ const onGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
}
|
||||
|
||||
let autoHeightSyncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const isGridApiAlive = (api: GridApi<DetailRow> | null | undefined): api is GridApi<DetailRow> =>
|
||||
Boolean(api && !api.isDestroyed?.())
|
||||
|
||||
const syncAutoRowHeights = async () => {
|
||||
await nextTick()
|
||||
const api = gridApi.value
|
||||
if (!api) return
|
||||
api.resetRowHeights()
|
||||
if (!isGridApiAlive(api)) return
|
||||
api.onRowHeightChanged()
|
||||
}
|
||||
|
||||
@ -214,6 +216,7 @@ const scheduleAutoRowHeights = () => {
|
||||
if (autoHeightSyncTimer) clearTimeout(autoHeightSyncTimer)
|
||||
autoHeightSyncTimer = setTimeout(() => {
|
||||
autoHeightSyncTimer = null
|
||||
if (!isGridApiAlive(gridApi.value)) return
|
||||
void syncAutoRowHeights()
|
||||
}, 0)
|
||||
}
|
||||
@ -1037,13 +1040,37 @@ onBeforeUnmount(() => {
|
||||
clearTimeout(autoHeightSyncTimer)
|
||||
autoHeightSyncTimer = null
|
||||
}
|
||||
if (isGridApiAlive(gridApi.value)) {
|
||||
gridApi.value.stopEditing()
|
||||
}
|
||||
gridApi.value = null
|
||||
})
|
||||
|
||||
/**
|
||||
* 处理表格单元格编辑:当前只接管 finalFee 列。
|
||||
* 编辑后仅重算固定行,避免覆盖用户刚输入的确认金额。
|
||||
*/
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitFinalFeeGridChanges = async () => {
|
||||
const currentState = getCurrentContractState()
|
||||
const finalRows = applyFixedRowSummary(currentState.detailRows)
|
||||
await setCurrentContractState({
|
||||
...currentState,
|
||||
detailRows: finalRows
|
||||
})
|
||||
const api = gridApi.value
|
||||
if (isGridApiAlive(api)) {
|
||||
const fixedRowData = finalRows.find(r => isFixedRow(r))
|
||||
const fixedNode = api.getRowNode(fixedBudgetRow.id)
|
||||
if (fixedNode && fixedRowData) {
|
||||
fixedNode.setData(fixedRowData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCellValueChanged = async (event: any) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
if (event.colDef?.field !== 'finalFee') return
|
||||
const row = event.data as DetailRow | undefined
|
||||
if (!row || isFixedRow(row)) return
|
||||
@ -1069,6 +1096,15 @@ const handleCellValueChanged = async (event: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
void commitFinalFeeGridChanges()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadProjectIndustry()
|
||||
await initializeContractState()
|
||||
@ -1087,16 +1123,21 @@ onActivated(async () => {
|
||||
@update:model-value="handleServiceSelectionChange" />
|
||||
|
||||
<div class="rounded-lg border bg-card xmMx flex min-h-0 flex-1 flex-col overflow-hidden">
|
||||
<div class="flex items-center justify-between border-b px-3 py-2">
|
||||
<h3 class="text-xs font-semibold text-foreground leading-none">
|
||||
咨询服务明细
|
||||
</h3>
|
||||
<div class="text-[11px] text-muted-foreground leading-none">按服务词典生成</div>
|
||||
<div class="flex items-start justify-between gap-3 border-b px-3 py-2">
|
||||
<div class="min-w-0 space-y-1">
|
||||
<h3 class="text-xs font-semibold text-foreground leading-none">
|
||||
咨询服务明细
|
||||
</h3>
|
||||
|
||||
</div>
|
||||
<p class="text-[11px] text-muted-foreground leading-none leading-4 text-amber-700/90">※ 请注意检查并修改《规范》建议的限值或特殊值,并在确认金额栏修改</p>
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue :style="agGridStyle" :rowData="detailRows" :columnDefs="columnDefs"
|
||||
:gridOptions="detailGridOptions" :theme="myTheme" @cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
|
||||
:animateRows="true"
|
||||
@grid-ready="onGridReady"
|
||||
@first-data-rendered="onFirstDataRendered"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
||||
import { taskList } from '@/sql'
|
||||
@ -11,6 +11,9 @@ import { syncPricingTotalToZxFw } from '@/lib/zxFwPricingSync'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { loadConsultCategoryFactorMap } from '@/lib/xmFactorDefaults'
|
||||
import { usePricingPaneLifecycle } from '@/lib/pricingScalePaneLifecycle'
|
||||
import { sumNullableBy } from '@/lib/pricingScaleCalc'
|
||||
import { createPinnedTopRowData } from '@/lib/pricingPinnedRows'
|
||||
import MethodUnavailableNotice from '@/components/shared/MethodUnavailableNotice.vue'
|
||||
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale';
|
||||
@ -187,6 +190,7 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
||||
const fromDb = dbValueMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
const hasRemark = Object.prototype.hasOwnProperty.call(fromDb, 'remark')
|
||||
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
|
||||
|
||||
return {
|
||||
...row,
|
||||
@ -195,7 +199,11 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
||||
budgetAdoptedUnitPrice:
|
||||
typeof fromDb.budgetAdoptedUnitPrice === 'number' ? fromDb.budgetAdoptedUnitPrice : null,
|
||||
consultCategoryFactor:
|
||||
typeof fromDb.consultCategoryFactor === 'number' ? fromDb.consultCategoryFactor : getDefaultConsultCategoryFactor(),
|
||||
typeof fromDb.consultCategoryFactor === 'number'
|
||||
? fromDb.consultCategoryFactor
|
||||
: hasConsultCategoryFactor
|
||||
? null
|
||||
: getDefaultConsultCategoryFactor(),
|
||||
serviceFee: typeof fromDb.serviceFee === 'number' ? fromDb.serviceFee : null,
|
||||
remark: typeof fromDb.remark === 'string' ? fromDb.remark : hasRemark ? '' : row.remark
|
||||
}
|
||||
@ -334,6 +342,8 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group &&
|
||||
!params.node?.rowPinned &&
|
||||
@ -353,6 +363,8 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && !isNoTaskRow(params.data),
|
||||
cellClass: params => (!params.node?.group && !params.node?.rowPinned ? 'editable-cell-line' : ''),
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group &&
|
||||
!params.node?.rowPinned &&
|
||||
@ -408,20 +420,9 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
|
||||
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, row => calcServiceFee(row)))
|
||||
const pinnedTopRowData = computed(() => [
|
||||
{
|
||||
const pinnedTopRowData = computed(() =>
|
||||
createPinnedTopRowData({
|
||||
id: 'pinned-total-row',
|
||||
taskCode: '总合计',
|
||||
taskName: '',
|
||||
@ -436,8 +437,8 @@ const pinnedTopRowData = computed(() => [
|
||||
serviceFee: totalServiceFee.value,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -531,32 +532,36 @@ const loadFromIndexedDB = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
if (isBulkClipboardMutation) return
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
await syncLinkedConsultFactorFromHt()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
await loadFromIndexedDB()
|
||||
await syncLinkedConsultFactorFromHt()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
gridApi.value?.stopEditing()
|
||||
gridApi.value = null
|
||||
void saveToIndexedDB()
|
||||
})
|
||||
|
||||
watch(linkedConsultFactorSignature, () => {
|
||||
void syncLinkedConsultFactorFromHt()
|
||||
usePricingPaneLifecycle({
|
||||
gridApi,
|
||||
loadFromIndexedDB,
|
||||
syncLinkedFields: syncLinkedConsultFactorFromHt,
|
||||
linkedSourceSignature: linkedConsultFactorSignature,
|
||||
saveToIndexedDB
|
||||
})
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
@ -566,6 +571,13 @@ const processCellForClipboard = (params: any) => {
|
||||
};
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'budgetAdoptedUnitPrice') {
|
||||
return parseSanitizedAdoptedPriceOrNull(params.value)
|
||||
}
|
||||
if (field === 'workload' || field === 'consultCategoryFactor') {
|
||||
return parseSanitizedNumberOrNull(params.value)
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
@ -606,6 +618,8 @@ const mydiyTheme = myTheme.withParams({
|
||||
:enableCellSpan="true"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged" :suppressColumnVirtualisation="true"
|
||||
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
|
||||
:suppressRowVirtualisation="true"
|
||||
|
||||
:cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||
|
||||
@ -246,11 +246,10 @@ const mergeWithDictRows = (rowsFromDb: DetailRow[] | undefined): DetailRow[] =>
|
||||
|
||||
const parseNonNegativeIntegerOrNull = (value: unknown) => {
|
||||
if (value === '' || value == null) return null
|
||||
if (typeof value === 'number') return Number.isInteger(value) && value >= 0 ? value : null
|
||||
const normalized = String(value).trim()
|
||||
if (!/^\d+$/.test(normalized)) return null
|
||||
const v = Number(normalized)
|
||||
return Number.isSafeInteger(v) ? v : null
|
||||
const parsed = parseNumberOrNull(value, { sanitize: true, precision: 0 })
|
||||
if (parsed == null) return null
|
||||
if (!Number.isSafeInteger(parsed) || parsed < 0) return null
|
||||
return parsed
|
||||
}
|
||||
|
||||
const formatEditableNumber = (params: any) => {
|
||||
@ -540,24 +539,43 @@ const loadFromIndexedDB = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = (source: string) => {
|
||||
syncServiceBudgetToRows()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
scheduleAutoRowHeights()
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event?: any) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
commitGridChanges('cell-value-changed')
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = (event?: any) => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges(event?.type || 'bulk-end')
|
||||
}
|
||||
|
||||
const handleGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
scheduleAutoRowHeights()
|
||||
}
|
||||
|
||||
let autoHeightSyncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const isGridApiAlive = (api: GridApi<DetailRow> | null | undefined): api is GridApi<DetailRow> =>
|
||||
Boolean(api && !api.isDestroyed?.())
|
||||
|
||||
const forceRefreshCellsOnLiveApi = () => {
|
||||
// 再次触发一轮强制刷新,覆盖 AG Grid 异步布局后的高度计算。
|
||||
setTimeout(() => {
|
||||
const liveApi = gridApi.value
|
||||
if (!liveApi || liveApi.isDestroyed?.()) return
|
||||
if (!isGridApiAlive(liveApi)) return
|
||||
liveApi.refreshCells({ force: true })
|
||||
liveApi.redrawRows()
|
||||
}, 16)
|
||||
@ -566,8 +584,7 @@ const forceRefreshCellsOnLiveApi = () => {
|
||||
const syncAutoRowHeights = async () => {
|
||||
await nextTick()
|
||||
const api = gridApi.value
|
||||
if (!api || api.isDestroyed?.()) return
|
||||
api.resetRowHeights()
|
||||
if (!isGridApiAlive(api)) return
|
||||
api.onRowHeightChanged()
|
||||
api.refreshCells({ force: true })
|
||||
api.redrawRows()
|
||||
@ -577,6 +594,7 @@ const scheduleAutoRowHeights = () => {
|
||||
if (autoHeightSyncTimer) clearTimeout(autoHeightSyncTimer)
|
||||
autoHeightSyncTimer = setTimeout(() => {
|
||||
autoHeightSyncTimer = null
|
||||
if (!isGridApiAlive(gridApi.value)) return
|
||||
void syncAutoRowHeights()
|
||||
}, 0)
|
||||
}
|
||||
@ -603,6 +621,13 @@ const processCellForClipboard = (params: any) => {
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'personnelCount') {
|
||||
return parseNonNegativeIntegerOrNull(params.value)
|
||||
}
|
||||
if (field === 'adoptedBudgetUnitPrice' || field === 'workdayCount') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
@ -672,6 +697,10 @@ onBeforeUnmount(() => {
|
||||
:animateRows="true"
|
||||
:treeData="false"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
|
||||
@ -413,12 +413,52 @@ const handleGridReady = (event: GridReadyEvent<FeeRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
syncComputedValuesToRows()
|
||||
gridApi.value?.refreshCells({ force: true })
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
if (isBulkClipboardMutation) return
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'quantity') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
if (field === 'unitPrice') {
|
||||
return parseNumberOrNull(params.value, { precision: 2 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
} catch (_error) {
|
||||
return params.value
|
||||
}
|
||||
return params.value
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
@ -465,10 +505,16 @@ onBeforeUnmount(() => {
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
:enableClipboard="true"
|
||||
:processCellForClipboard="processCellForClipboard"
|
||||
:processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true"
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -611,11 +611,27 @@ const handleGridReady = (event: GridReadyEvent<FeeMethodRow>) => {
|
||||
gridApi.value = event.api
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||
if (isSummaryRow(event.data)) return
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
void saveToIndexedDB()
|
||||
}
|
||||
|
||||
const handleCellValueChanged = (event: CellValueChangedEvent<FeeMethodRow>) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
if (isSummaryRow(event.data)) return
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadFromIndexedDB()
|
||||
})
|
||||
@ -709,6 +725,10 @@ onBeforeUnmount(() => {
|
||||
:undoRedoCellEditingLimit="20"
|
||||
@grid-ready="handleGridReady"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,7 @@ import {
|
||||
AlertDialogTitle
|
||||
} from 'reka-ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { myTheme, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { agGridDefaultColDef, myTheme, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { getServiceDictItemById, wholeProcessTasks, workList } from '@/sql'
|
||||
import { WorkType,TYPE_LABEL_MAP } from '@/sql'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
@ -72,6 +72,12 @@ const gridApi = ref<GridApi<WorkContentRow> | null>(null)
|
||||
const rowData = ref<WorkContentRow[]>([])
|
||||
const isWholeProcessGroupedMode = ref(false)
|
||||
const groupedServiceGroups = ref<string[]>([])
|
||||
const defaultColDef = {
|
||||
...(agGridDefaultColDef as ColDef<WorkContentRow>),
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false
|
||||
}
|
||||
|
||||
const syncGroupedRowsRender = async () => {
|
||||
await nextTick()
|
||||
@ -710,7 +716,9 @@ const confirmDeleteRow = () => {
|
||||
:tooltipShowDelay="500"
|
||||
:singleClickEdit="true"
|
||||
:stopEditingWhenCellsLoseFocus="true"
|
||||
:defaultColDef="{ resizable: true, sortable: false, filter: false }"
|
||||
:enterNavigatesVertically="true"
|
||||
:enterNavigatesVerticallyAfterEdit="true"
|
||||
:defaultColDef="defaultColDef"
|
||||
:suppressColumnVirtualisation="false"
|
||||
:suppressRowVirtualisation="false"
|
||||
@grid-ready="onGridReady"
|
||||
|
||||
@ -287,13 +287,29 @@ const loadFromIndexedDB = async () => {
|
||||
}
|
||||
|
||||
let gridPersistTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const handleCellValueChanged = () => {
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const scheduleGridPersist = () => {
|
||||
if (gridPersistTimer) clearTimeout(gridPersistTimer)
|
||||
gridPersistTimer = setTimeout(() => {
|
||||
void saveToIndexedDB()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
const handleCellValueChanged = () => {
|
||||
if (isBulkClipboardMutation) return
|
||||
scheduleGridPersist()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
scheduleGridPersist()
|
||||
}
|
||||
|
||||
const processCellForClipboard = (params: any) => {
|
||||
if (Array.isArray(params.value)) {
|
||||
return JSON.stringify(params.value)
|
||||
@ -302,6 +318,10 @@ const processCellForClipboard = (params: any) => {
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'budgetValue') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
@ -351,6 +371,10 @@ onBeforeUnmount(() => {
|
||||
:animateRows="true"
|
||||
:treeData="true"
|
||||
@cell-value-changed="handleCellValueChanged"
|
||||
@paste-start="handleBulkMutationStart"
|
||||
@paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart"
|
||||
@fill-end="handleBulkMutationEnd"
|
||||
:suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true"
|
||||
:cellSelection="{ handle: { mode: 'range' } }"
|
||||
|
||||
@ -1,15 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { AgGridVue } from 'ag-grid-vue3'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community'
|
||||
import type { CellValueChangedEvent, ColDef, GridApi, GridOptions, GridReadyEvent } from 'ag-grid-community'
|
||||
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
|
||||
import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgGridOptions'
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { parseNumberOrNull } from '@/lib/number'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import { syncContractScaleToPricing } from '@/lib/zxFwPricingSync'
|
||||
import { syncContractScaleToPricing, type ContractScaleSyncResult } from '@/lib/zxFwPricingSync'
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import {
|
||||
ToastAction,
|
||||
ToastDescription,
|
||||
ToastProvider,
|
||||
ToastRoot,
|
||||
ToastTitle,
|
||||
ToastViewport
|
||||
} from 'reka-ui'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
|
||||
|
||||
@ -43,6 +53,7 @@ interface XmBaseInfoState {
|
||||
|
||||
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
|
||||
const CONTRACT_SCALE_KEY_PREFIX = 'ht-info-v3-'
|
||||
const CONTRACT_SCALE_CHANGE_KEY_PREFIX = 'ht-info-scale-change-v1-'
|
||||
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||
const kvStore = useKvStore()
|
||||
|
||||
@ -210,6 +221,7 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
|
||||
if (!activeIndustryId.value) {
|
||||
detailDict.value = []
|
||||
detailRows.value = []
|
||||
lastPersistedLeafRows = []
|
||||
roughCalcEnabled.value = false
|
||||
applyPinnedTotalAmount(api, null)
|
||||
return
|
||||
@ -242,6 +254,7 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
|
||||
Number.isFinite(contractData.totalAmount)
|
||||
if (hasContractRows && !isLegacyEmptyScaleRows) {
|
||||
detailRows.value = mergeWithDictRows(contractRows)
|
||||
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
|
||||
return
|
||||
}
|
||||
|
||||
@ -253,10 +266,12 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
|
||||
|
||||
if (Array.isArray(xmData?.detailRows) && xmData.detailRows.length > 0) {
|
||||
detailRows.value = mergeWithDictRows(xmData.detailRows)
|
||||
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
|
||||
return
|
||||
}
|
||||
}
|
||||
detailRows.value = buildDefaultRows()
|
||||
lastPersistedLeafRows = cloneLeafRows(detailRows.value)
|
||||
|
||||
|
||||
void saveToIndexedDB()
|
||||
@ -264,6 +279,7 @@ const loadFromIndexedDB = async (api: GridApi<DetailRow>) => {
|
||||
console.error('loadFromIndexedDB failed:', error)
|
||||
activeIndustryId.value = ''
|
||||
detailRows.value = []
|
||||
lastPersistedLeafRows = []
|
||||
roughCalcEnabled.value = false
|
||||
applyPinnedTotalAmount(api, null)
|
||||
}
|
||||
@ -294,6 +310,11 @@ interface GridPersistState {
|
||||
totalAmount?: number | null
|
||||
}
|
||||
|
||||
interface ContractScaleChangeState {
|
||||
changedRowIds: string[]
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
dbKey: string
|
||||
@ -352,11 +373,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
: !params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasCost) && (params.value == null || params.value === '')
|
||||
},
|
||||
aggFunc: decimalAggSum,
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 3) : null
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (roughCalcEnabled.value) {
|
||||
if (!params.node?.rowPinned) return ''
|
||||
@ -389,11 +406,7 @@ const columnDefs: ColDef<DetailRow>[] = [
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && Boolean(params.data?.hasArea) && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => {
|
||||
if (params.newValue === '' || params.newValue == null) return null
|
||||
const v = Number(params.newValue)
|
||||
return Number.isFinite(v) ? roundTo(v, 3) : null
|
||||
},
|
||||
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => {
|
||||
if (roughCalcEnabled.value) {
|
||||
return ''
|
||||
@ -441,14 +454,46 @@ const pinnedTopRowData = ref<DetailRow[]>([
|
||||
path: ['TOTAL']
|
||||
}
|
||||
])
|
||||
const syncToastOpen = ref(false)
|
||||
const syncToastText = ref('')
|
||||
let lastPersistedLeafRows: DetailRow[] | null = null
|
||||
|
||||
const cloneLeafRows = (rows: DetailRow[]) =>
|
||||
rows.map(row => ({
|
||||
...JSON.parse(JSON.stringify(row)),
|
||||
hide: Boolean(row.hide),
|
||||
isGroupRow: false
|
||||
}))
|
||||
|
||||
const getChangedScaleRowIds = (previousLeafRows: DetailRow[], nextLeafRows: DetailRow[]) => {
|
||||
const previousRowMap = new Map(previousLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
|
||||
const nextRowMap = new Map(nextLeafRows.map(row => [String(row?.id || '').trim(), row] as const))
|
||||
return Array.from(new Set([
|
||||
...previousRowMap.keys(),
|
||||
...nextRowMap.keys()
|
||||
])).filter(rowId => {
|
||||
const prevRow = previousRowMap.get(rowId)
|
||||
const nextRow = nextRowMap.get(rowId)
|
||||
const prevAmount = typeof prevRow?.amount === 'number' && Number.isFinite(prevRow.amount) ? roundTo(prevRow.amount, 6) : null
|
||||
const nextAmount = typeof nextRow?.amount === 'number' && Number.isFinite(nextRow.amount) ? roundTo(nextRow.amount, 6) : null
|
||||
const prevLandArea = typeof prevRow?.landArea === 'number' && Number.isFinite(prevRow.landArea) ? roundTo(prevRow.landArea, 6) : null
|
||||
const nextLandArea = typeof nextRow?.landArea === 'number' && Number.isFinite(nextRow.landArea) ? roundTo(nextRow.landArea, 6) : null
|
||||
return prevAmount !== nextAmount || prevLandArea !== nextLandArea
|
||||
})
|
||||
}
|
||||
|
||||
const showScaleSyncToast = (result: ContractScaleSyncResult) => {
|
||||
if (result.updatedMethodCount <= 0) return
|
||||
syncToastText.value = `规模信息已同步到咨询服务(${result.updatedServiceCount} 项服务,${result.updatedMethodCount} 个计价页,${result.updatedRowCount} 行)`
|
||||
syncToastOpen.value = false
|
||||
requestAnimationFrame(() => {
|
||||
syncToastOpen.value = true
|
||||
})
|
||||
}
|
||||
|
||||
const saveToIndexedDB = async () => {
|
||||
try {
|
||||
const leafRows = detailRows.value.map(row => ({
|
||||
...JSON.parse(JSON.stringify(row)),
|
||||
hide: Boolean(row.hide),
|
||||
isGroupRow: false
|
||||
}))
|
||||
const leafRows = cloneLeafRows(detailRows.value)
|
||||
const totalAmountFromRows = (() => {
|
||||
let hasValue = false
|
||||
let total = 0
|
||||
@ -473,10 +518,18 @@ const saveToIndexedDB = async () => {
|
||||
payload.roughCalcEnabled = roughCalcEnabled.value
|
||||
payload.totalAmount = normalizedTotalAmount
|
||||
await kvStore.setItem(props.dbKey, payload)
|
||||
const previousLeafRows = lastPersistedLeafRows || leafRows
|
||||
const changedRowIds = getChangedScaleRowIds(previousLeafRows, leafRows)
|
||||
lastPersistedLeafRows = cloneLeafRows(leafRows)
|
||||
if (props.dbKey.startsWith(CONTRACT_SCALE_KEY_PREFIX)) {
|
||||
const contractId = props.dbKey.slice(CONTRACT_SCALE_KEY_PREFIX.length).trim()
|
||||
if (contractId) {
|
||||
await syncContractScaleToPricing(contractId)
|
||||
if (contractId && changedRowIds.length > 0) {
|
||||
await kvStore.setItem<ContractScaleChangeState>(`${CONTRACT_SCALE_CHANGE_KEY_PREFIX}${contractId}`, {
|
||||
changedRowIds,
|
||||
updatedAt: Date.now()
|
||||
})
|
||||
const syncResult = await syncContractScaleToPricing(contractId, { changedRowIds })
|
||||
showScaleSyncToast(syncResult)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@ -491,6 +544,12 @@ const schedulePersist = () => {
|
||||
}, 600)
|
||||
}
|
||||
|
||||
const getRowId = (params: { data?: DetailRow }) => String(params.data?.id || '')
|
||||
const detailGridOptions: GridOptions<DetailRow> = {
|
||||
...gridOptions,
|
||||
getRowId
|
||||
}
|
||||
|
||||
const handleFlushPersistRequest = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<{ done?: () => void }>
|
||||
const done = customEvent?.detail?.done
|
||||
@ -510,6 +569,24 @@ const setDetailRowsHidden = (hidden: boolean) => {
|
||||
}
|
||||
|
||||
let oldValue:number|null
|
||||
let isBulkClipboardMutation = false
|
||||
|
||||
const commitGridChanges = () => {
|
||||
if (roughCalcEnabled.value) {
|
||||
const rawAmount = pinnedTopRowData.value[0]?.amount
|
||||
const parsed = typeof rawAmount === 'number' ? rawAmount : Number(rawAmount)
|
||||
const nextAmount = Number.isFinite(parsed) ? roundTo(parsed, 2) : null
|
||||
pinnedTopRowData.value[0].amount = nextAmount
|
||||
const pinnedTopNode = gridApi.value?.getPinnedTopRow(0)
|
||||
if (pinnedTopNode) {
|
||||
pinnedTopNode.setDataValue('amount', nextAmount)
|
||||
}
|
||||
} else {
|
||||
syncPinnedTotalForNormalMode()
|
||||
}
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
const onRoughCalcSwitch = (checked: boolean) => {
|
||||
gridApi.value?.stopEditing(true)
|
||||
roughCalcEnabled.value = checked
|
||||
@ -530,6 +607,7 @@ const onRoughCalcSwitch = (checked: boolean) => {
|
||||
|
||||
|
||||
const onCellValueChanged = (event: CellValueChangedEvent) => {
|
||||
if (isBulkClipboardMutation) return
|
||||
if (roughCalcEnabled.value && event.node?.rowPinned && event.colDef.field === 'amount') {
|
||||
if (typeof event.newValue === 'number') {
|
||||
pinnedTopRowData.value[0].amount = roundTo(event.newValue, 2)
|
||||
@ -544,6 +622,15 @@ const onCellValueChanged = (event: CellValueChangedEvent) => {
|
||||
schedulePersist()
|
||||
}
|
||||
|
||||
const handleBulkMutationStart = () => {
|
||||
isBulkClipboardMutation = true
|
||||
}
|
||||
|
||||
const handleBulkMutationEnd = () => {
|
||||
isBulkClipboardMutation = false
|
||||
commitGridChanges()
|
||||
}
|
||||
|
||||
const onGridReady = (event: GridReadyEvent<DetailRow>) => {
|
||||
gridApi.value = event.api
|
||||
|
||||
@ -561,6 +648,10 @@ const processCellForClipboard = (params: any) => {
|
||||
}
|
||||
|
||||
const processCellFromClipboard = (params: any) => {
|
||||
const field = String(params.column?.getColDef?.().field || '')
|
||||
if (field === 'amount' || field === 'landArea') {
|
||||
return parseNumberOrNull(params.value, { precision: 3 })
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(params.value)
|
||||
if (Array.isArray(parsed)) return parsed
|
||||
@ -618,34 +709,55 @@ onMounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="h-full">
|
||||
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3
|
||||
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<!-- <div class="flex items-center gap-2">
|
||||
<span class=" text-xs text-muted-foreground">简要计算</span>
|
||||
<SwitchRoot
|
||||
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
|
||||
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
|
||||
<SwitchThumb
|
||||
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
|
||||
</SwitchRoot>
|
||||
</div> -->
|
||||
</div>
|
||||
<ToastProvider>
|
||||
<div class="h-full">
|
||||
<div class="rounded-lg border bg-card xmMx scroll-mt-3 flex flex-col overflow-hidden h-full">
|
||||
<div class="flex items-center justify-between border-b px-4 py-3">
|
||||
<h3
|
||||
class="text-sm font-semibold text-foreground cursor-pointer select-none transition-colors hover:text-primary">
|
||||
{{ props.title }}
|
||||
</h3>
|
||||
<!-- <div class="flex items-center gap-2">
|
||||
<span class=" text-xs text-muted-foreground">简要计算</span>
|
||||
<SwitchRoot
|
||||
class="cursor-pointer peer h-5 w-9 shrink-0 rounded-full border border-transparent bg-muted shadow-sm transition-colors data-[state=checked]:bg-primary"
|
||||
:modelValue="roughCalcEnabled" @update:modelValue="onRoughCalcSwitch">
|
||||
<SwitchThumb
|
||||
class="block h-4 w-4 translate-x-0.5 rounded-full bg-background shadow transition-transform data-[state=checked]:translate-x-4" />
|
||||
</SwitchRoot>
|
||||
</div> -->
|
||||
</div>
|
||||
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="gridOptions" :theme="myTheme"
|
||||
:animateRows="true"
|
||||
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
|
||||
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||
<div :class="agGridWrapClass">
|
||||
<AgGridVue :style="agGridStyle" :rowData="visibleRowData" :pinnedTopRowData="pinnedTopRowData"
|
||||
:columnDefs="columnDefs" :autoGroupColumnDef="autoGroupColumnDef" :gridOptions="detailGridOptions" :theme="myTheme"
|
||||
:animateRows="true"
|
||||
@grid-ready="onGridReady" @cell-value-changed="onCellValueChanged" :suppressColumnVirtualisation="true"
|
||||
@paste-start="handleBulkMutationStart" @paste-end="handleBulkMutationEnd"
|
||||
@fill-start="handleBulkMutationStart" @fill-end="handleBulkMutationEnd"
|
||||
:suppressRowVirtualisation="true" :cellSelection="{ handle: { mode: 'range' } }" :enableClipboard="true"
|
||||
:localeText="AG_GRID_LOCALE_CN" :tooltipShowDelay="500" :headerHeight="50" :suppressHorizontalScroll="true"
|
||||
:processCellForClipboard="processCellForClipboard" :processCellFromClipboard="processCellFromClipboard"
|
||||
:undoRedoCellEditing="true" :undoRedoCellEditingLimit="20" />
|
||||
</div>
|
||||
</div>
|
||||
<ToastRoot
|
||||
v-model:open="syncToastOpen"
|
||||
class="group pointer-events-auto rounded-lg border bg-background px-4 py-3 text-sm shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in data-[state=closed]:fade-out data-[state=open]:slide-in-from-bottom-2"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="space-y-1">
|
||||
<ToastTitle class="text-sm font-semibold text-foreground">已同步咨询服务</ToastTitle>
|
||||
<ToastDescription class="text-xs text-muted-foreground">{{ syncToastText }}</ToastDescription>
|
||||
</div>
|
||||
<ToastAction as-child alt-text="关闭">
|
||||
<Button variant="ghost" size="sm" class="h-7 px-2 text-xs" @click="syncToastOpen = false">
|
||||
关闭
|
||||
</Button>
|
||||
</ToastAction>
|
||||
</div>
|
||||
</ToastRoot>
|
||||
<ToastViewport class="fixed bottom-5 right-5 z-[85] flex w-[420px] max-w-[92vw] flex-col gap-2 outline-none" />
|
||||
</div>
|
||||
</div>
|
||||
</ToastProvider>
|
||||
</template>
|
||||
|
||||
@ -190,10 +190,9 @@ const hasResolvedMajor = computed(() => effectiveMajorDictItem.value != null)
|
||||
const majorSupportsCostScale = computed(() => effectiveMajorDictItem.value?.hasCost === true)
|
||||
const majorSupportsLandScale = computed(() => effectiveMajorDictItem.value?.hasArea === true)
|
||||
const preferLandScaleForDualMajor = computed(() => majorSupportsCostScale.value && majorSupportsLandScale.value)
|
||||
const workEnvCoefficient = computed(() => {
|
||||
const parsed = Number(workEnvFactor.value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
})
|
||||
const workEnvCoefficient = computed(() =>
|
||||
parseNumberOrNull(workEnvFactor.value, { sanitize: true, precision: 3 })
|
||||
)
|
||||
const consultSupportsScale = computed(() => selectedConsultDictItem.value?.scale === true)
|
||||
const consultOnlySupportsCostScale = computed(() => selectedConsultDictItem.value?.onlyCostScale === true)
|
||||
const canUseInvestScale = computed(() =>
|
||||
@ -244,10 +243,8 @@ const scaleBudgetPreview = computed(() => {
|
||||
{ sanitize: true, precision: 3 }
|
||||
)
|
||||
if (scaleValue == null) return null
|
||||
console.log(mode)
|
||||
|
||||
const rawSplit = getBenchmarkBudgetSplitByScale(scaleValue, mode)
|
||||
console.log(rawSplit)
|
||||
if (!rawSplit) return null
|
||||
|
||||
const checkedSplit = {
|
||||
@ -305,6 +302,11 @@ const applyScaleInput = (field: 'invest' | 'land') => {
|
||||
landScale.value = normalized
|
||||
}
|
||||
|
||||
const applyWorkEnvFactorInput = () => {
|
||||
const next = parseNumberOrNull(workEnvFactor.value, { sanitize: true, precision: 3 })
|
||||
workEnvFactor.value = next == null ? '' : String(next)
|
||||
}
|
||||
|
||||
const totalSelectedCount = computed(() => {
|
||||
let count = 0
|
||||
if (selectedConsultKey.value) count += 1
|
||||
@ -595,6 +597,7 @@ watch(canUseLandScale, enabled => {
|
||||
:disabled="!canUseInvestScale"
|
||||
:placeholder="investScalePlaceholder"
|
||||
@blur="applyScaleInput('invest')"
|
||||
@keydown.enter.prevent="applyScaleInput('invest')"
|
||||
>
|
||||
</label>
|
||||
|
||||
@ -609,6 +612,7 @@ watch(canUseLandScale, enabled => {
|
||||
:disabled="!canUseLandScale"
|
||||
:placeholder="landScalePlaceholder"
|
||||
@blur="applyScaleInput('land')"
|
||||
@keydown.enter.prevent="applyScaleInput('land')"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
@ -656,7 +660,13 @@ watch(canUseLandScale, enabled => {
|
||||
|
||||
<div class="quick-calc-field">
|
||||
<span class="quick-calc-field__label">工作环境系数</span>
|
||||
<input v-model="workEnvFactor" class="quick-calc-field__input" placeholder="默认 1">
|
||||
<input
|
||||
v-model="workEnvFactor"
|
||||
class="quick-calc-field__input"
|
||||
placeholder="默认 1"
|
||||
@blur="applyWorkEnvFactorInput"
|
||||
@keydown.enter.prevent="applyWorkEnvFactorInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="quick-calc-field">
|
||||
|
||||
@ -29,6 +29,7 @@ import {
|
||||
ToastViewport
|
||||
} from 'reka-ui'
|
||||
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||||
import { formatExportTimestamp } from '@/lib/contractSegment'
|
||||
import {
|
||||
PROJECT_TAB_ID,
|
||||
QUICK_TAB_ID,
|
||||
@ -37,8 +38,32 @@ import {
|
||||
writeWorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
import { exportFile, serviceList } from '@/sql'
|
||||
import {
|
||||
buildMethod0,
|
||||
buildMethod1,
|
||||
buildMethod2,
|
||||
buildMethod3,
|
||||
buildMethod4,
|
||||
buildMethod5,
|
||||
buildProjectMajorCoes,
|
||||
buildProjectServiceCoes,
|
||||
buildServiceFee,
|
||||
buildServiceFinalFee,
|
||||
getExpertIdFromRowId,
|
||||
getTaskIdFromRowId,
|
||||
groupWorkContentTasks,
|
||||
hasServiceId,
|
||||
isNonEmptyString,
|
||||
mapIndustryCodeToExportIndustry,
|
||||
sortServiceIdsByDict,
|
||||
sumNumbers,
|
||||
toExportScaleRows,
|
||||
toFiniteNumber,
|
||||
toFiniteNumberOrZero,
|
||||
toSafeInteger,
|
||||
toMoney
|
||||
} from '@/lib/reportExportBuilders'
|
||||
import { exportFile } from '@/sql'
|
||||
|
||||
interface DataEntry {
|
||||
key: string
|
||||
@ -203,6 +228,7 @@ interface FactorRowLike {
|
||||
}
|
||||
|
||||
interface ExportScaleRow {
|
||||
majorid: number
|
||||
major: number
|
||||
cost: number | null
|
||||
area: number | null
|
||||
@ -1013,15 +1039,6 @@ const sanitizeFileNamePart = (value: string): string => {
|
||||
return cleaned || '造价项目'
|
||||
}
|
||||
|
||||
const formatExportTimestamp = (date: Date): string => {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}`
|
||||
}
|
||||
|
||||
const getExportProjectName = (entries: DataEntry[]): string => {
|
||||
const target =
|
||||
entries.find(item => item.key === PROJECT_INFO_DB_KEY) ||
|
||||
@ -1030,413 +1047,16 @@ const getExportProjectName = (entries: DataEntry[]): string => {
|
||||
return typeof data.projectName === 'string' ? sanitizeFileNamePart(data.projectName) : '造价项目'
|
||||
}
|
||||
|
||||
const toFiniteNumber = (value: unknown): number | null => {
|
||||
const num = Number(value)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
|
||||
const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0
|
||||
|
||||
const toSafeInteger = (value: unknown): number | null => {
|
||||
const num = Number(value)
|
||||
if (!Number.isInteger(num)) return null
|
||||
if (!Number.isSafeInteger(num)) return null
|
||||
return num
|
||||
}
|
||||
|
||||
const sumNumbers = (values: Array<number | null | undefined>): number =>
|
||||
values.reduce<number>(
|
||||
(sum, value) => sum + (typeof value === 'number' && Number.isFinite(value) ? value : 0),
|
||||
0
|
||||
)
|
||||
const toMoney = (value: unknown): number => roundTo(toFiniteNumber(value) ?? 0, 2)
|
||||
|
||||
const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
const getTaskIdFromRowId = (value: string): number | null => {
|
||||
const match = /^task-(\d+)-\d+$/.exec(value)
|
||||
return match ? toSafeInteger(match[1]) : null
|
||||
}
|
||||
|
||||
const getExpertIdFromRowId = (value: string): number | null => {
|
||||
const match = /^expert-(\d+)$/.exec(value)
|
||||
return match ? toSafeInteger(match[1]) : null
|
||||
}
|
||||
|
||||
const hasServiceId = (serviceId: string) =>
|
||||
Object.prototype.hasOwnProperty.call(serviceList as Record<string, unknown>, serviceId)
|
||||
|
||||
const sortServiceIdsByDict = (ids: string[]) =>
|
||||
[...ids].sort((left, right) => {
|
||||
const leftOrder = Number((serviceList as Record<string, any>)[left]?.order)
|
||||
const rightOrder = Number((serviceList as Record<string, any>)[right]?.order)
|
||||
const safeLeft = Number.isFinite(leftOrder) ? leftOrder : Number.MAX_SAFE_INTEGER
|
||||
const safeRight = Number.isFinite(rightOrder) ? rightOrder : Number.MAX_SAFE_INTEGER
|
||||
return safeLeft - safeRight
|
||||
})
|
||||
|
||||
const mapIndustryCodeToExportIndustry = (value: unknown): number => {
|
||||
const raw = typeof value === 'string' ? value.trim().toUpperCase() : ''
|
||||
if (!raw) return 0
|
||||
if (raw === '0' || raw === 'E2') return 0
|
||||
if (raw === '1' || raw === 'E3') return 1
|
||||
if (raw === '2' || raw === 'E4') return 2
|
||||
return 0
|
||||
}
|
||||
|
||||
const parseScaleScopedRowId = (rowId: unknown) => {
|
||||
const raw = String(rowId || '').trim()
|
||||
const scopedMatch = /^(\d+)::(.+)$/.exec(raw)
|
||||
if (scopedMatch) {
|
||||
return {
|
||||
proNum: toSafeInteger(scopedMatch[1]) ?? 1,
|
||||
majorPart: String(scopedMatch[2] || '').trim()
|
||||
}
|
||||
}
|
||||
return {
|
||||
proNum: 1,
|
||||
majorPart: raw
|
||||
}
|
||||
}
|
||||
|
||||
const toScaleMajorId = (row: ScaleMethodRowLike): number | null => {
|
||||
const direct = toSafeInteger((row as { majorDictId?: unknown }).majorDictId)
|
||||
if (direct != null) return direct
|
||||
const parsed = parseScaleScopedRowId(row.id)
|
||||
return toSafeInteger(parsed.majorPart)
|
||||
}
|
||||
|
||||
const toScaleProNum = (row: ScaleMethodRowLike): number => {
|
||||
const parsed = parseScaleScopedRowId(row.id)
|
||||
return parsed.proNum > 0 ? parsed.proNum : 1
|
||||
}
|
||||
|
||||
const normalizeTaskText = (value: unknown): string => String(value || '').trim()
|
||||
const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null =>
|
||||
toSafeInteger((row as { serviceid?: unknown })?.serviceid)
|
||||
const resolveScaleMethodFee = (row: ScaleMethodRowLike, mode: 'cost' | 'area') => {
|
||||
const scaleValue = mode === 'cost' ? toFiniteNumber(row.amount) : toFiniteNumber(row.landArea)
|
||||
const benchmarkSplit = getBenchmarkBudgetSplitByScale(scaleValue, mode)
|
||||
const basicChecked = row.benchmarkBudgetBasicChecked !== false
|
||||
const optionalChecked = row.benchmarkBudgetOptionalChecked !== false
|
||||
const allUnchecked = !basicChecked && !optionalChecked
|
||||
const benchmarkBudgetBasic = benchmarkSplit ? (basicChecked ? benchmarkSplit.basic : 0) : null
|
||||
const benchmarkBudgetOptional = benchmarkSplit ? (optionalChecked ? benchmarkSplit.optional : 0) : null
|
||||
const computedSplit = benchmarkSplit
|
||||
? getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic,
|
||||
benchmarkBudgetOptional,
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})
|
||||
: null
|
||||
const basicFee = allUnchecked ? null : (toFiniteNumber(row.budgetFee) ?? computedSplit?.total ?? null)
|
||||
const basicFeeBasic = allUnchecked ? null : (toFiniteNumber(row.budgetFeeBasic) ?? computedSplit?.basic ?? null)
|
||||
const basicFeeOptional = allUnchecked ? null : (toFiniteNumber(row.budgetFeeOptional) ?? computedSplit?.optional ?? null)
|
||||
const basicFormula = typeof row.basicFormula === 'string' && row.basicFormula.trim()
|
||||
? row.basicFormula
|
||||
: (basicChecked ? (benchmarkSplit?.basicFormula ?? '') : '')
|
||||
const optionalFormula = typeof row.optionalFormula === 'string' && row.optionalFormula.trim()
|
||||
? row.optionalFormula
|
||||
: (optionalChecked ? (benchmarkSplit?.optionalFormula ?? '') : '')
|
||||
return {
|
||||
basicFee,
|
||||
basicFeeBasic,
|
||||
basicFeeOptional,
|
||||
basicFormula,
|
||||
optionalFormula
|
||||
}
|
||||
}
|
||||
|
||||
const groupWorkContentTasks = (
|
||||
rows: WorkContentRowLike[] | undefined,
|
||||
options?: { forceUngroup?: boolean }
|
||||
): ExportTaskGroup[] => {
|
||||
const source = Array.isArray(rows) ? rows : []
|
||||
const selected = source.filter(item => {
|
||||
if (item && item.isAddTrigger === true) return false
|
||||
const isCustom = Boolean(item?.custom)
|
||||
const isChecked = Boolean(item?.checked)
|
||||
return isCustom || isChecked
|
||||
})
|
||||
if (selected.length === 0) return []
|
||||
|
||||
const hasGroup = !options?.forceUngroup && selected.some(item => resolveTaskRowServiceId(item) != null)
|
||||
if (!hasGroup) {
|
||||
const text = selected
|
||||
.map(item => normalizeTaskText(item?.content))
|
||||
.filter(Boolean)
|
||||
return text.length > 0 ? [{ text }] : []
|
||||
}
|
||||
|
||||
const grouped = new Map<number, string[]>()
|
||||
const orderedServiceIds: number[] = []
|
||||
const ungroupedText: string[] = []
|
||||
for (const item of selected) {
|
||||
const content = normalizeTaskText(item?.content)
|
||||
if (!content) continue
|
||||
const serviceid = resolveTaskRowServiceId(item)
|
||||
if (serviceid == null) {
|
||||
ungroupedText.push(content)
|
||||
continue
|
||||
}
|
||||
if (!grouped.has(serviceid)) {
|
||||
grouped.set(serviceid, [])
|
||||
orderedServiceIds.push(serviceid)
|
||||
}
|
||||
grouped.get(serviceid)?.push(content)
|
||||
}
|
||||
|
||||
const groupedTasks: ExportTaskGroup[] = []
|
||||
for (const serviceid of orderedServiceIds) {
|
||||
const text = grouped.get(serviceid) || []
|
||||
if (text.length === 0) continue
|
||||
groupedTasks.push({ serviceid, text })
|
||||
}
|
||||
|
||||
if (ungroupedText.length > 0) {
|
||||
groupedTasks.push({ text: ungroupedText })
|
||||
}
|
||||
return groupedTasks
|
||||
}
|
||||
|
||||
const buildProjectServiceCoes = (rows: FactorRowLike[] | undefined): ExportServiceCoe[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows
|
||||
.map(row => {
|
||||
const serviceid = toSafeInteger(row.id)
|
||||
if (serviceid == null || row.budgetValue == null) return null
|
||||
const coe = toFiniteNumber(row.budgetValue)
|
||||
return {
|
||||
serviceid,
|
||||
coe,
|
||||
remark: row.remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportServiceCoe => Boolean(item))
|
||||
}
|
||||
|
||||
const buildProjectMajorCoes = (rows: FactorRowLike[] | undefined): ExportMajorCoe[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows
|
||||
.map(row => {
|
||||
const majorid = toSafeInteger(row.id)
|
||||
if (majorid == null || row.budgetValue == null) return null
|
||||
const coe = toFiniteNumber(row.budgetValue)
|
||||
return {
|
||||
majorid,
|
||||
coe,
|
||||
remark: row.remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMajorCoe => Boolean(item))
|
||||
}
|
||||
|
||||
const toExportScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows
|
||||
.map(row => {
|
||||
|
||||
if (row.id == null || (row.amount == null && row.landArea == null)) return null
|
||||
return {
|
||||
major: toSafeInteger(row.id),
|
||||
cost: row.amount,
|
||||
area: row.landArea
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportScaleRow => Boolean(item))
|
||||
}
|
||||
|
||||
|
||||
|
||||
const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | null => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const proSet = new Set<number>()
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const major = toScaleMajorId(row)
|
||||
if (major == null) return null
|
||||
const proNum = toScaleProNum(row)
|
||||
proSet.add(proNum)
|
||||
const cost = toFiniteNumber(row.amount)
|
||||
const feeResolved = resolveScaleMethodFee(row, 'cost')
|
||||
const basicFee = feeResolved.basicFee
|
||||
if (basicFee != null) hasTotalValue = true
|
||||
const basicFeeBasic = feeResolved.basicFeeBasic
|
||||
const basicFeeOptional = feeResolved.basicFeeOptional
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
if (basicFee == null) return null
|
||||
return {
|
||||
proNum,
|
||||
major,
|
||||
cost: cost ?? 0,
|
||||
basicFee: toMoney(basicFee),
|
||||
basicFormula: feeResolved.basicFormula,
|
||||
basicFee_basic: toMoney(basicFeeBasic),
|
||||
optionalFormula: feeResolved.optionalFormula,
|
||||
basicFee_optional: toMoney(basicFeeOptional),
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||||
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
|
||||
proportion: toFiniteNumber(row.workRatio) ?? 1,
|
||||
fee: toMoney(basicFee),
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod1Detail => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
proAmount: proSet.size > 0 ? proSet.size : 1,
|
||||
cost: sumNumbers(det.map(item => item.cost)),
|
||||
basicFee: toMoney(sumNumbers(det.map(item => item.basicFee))),
|
||||
basicFee_basic: toMoney(sumNumbers(det.map(item => item.basicFee_basic))),
|
||||
basicFee_optional: toMoney(sumNumbers(det.map(item => item.basicFee_optional))),
|
||||
fee: toMoney(sumNumbers(det.map(item => item.fee))),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | null => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const proSet = new Set<number>()
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const major = toScaleMajorId(row)
|
||||
if (major == null) return null
|
||||
const proNum = toScaleProNum(row)
|
||||
proSet.add(proNum)
|
||||
const area = toFiniteNumber(row.landArea)
|
||||
const feeResolved = resolveScaleMethodFee(row, 'area')
|
||||
const basicFee = feeResolved.basicFee
|
||||
if (basicFee != null) hasTotalValue = true
|
||||
const basicFeeBasic = feeResolved.basicFeeBasic
|
||||
const basicFeeOptional = feeResolved.basicFeeOptional
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
if (basicFee == null) return null
|
||||
return {
|
||||
proNum,
|
||||
major,
|
||||
area: area ?? 0,
|
||||
basicFee: toMoney(basicFee),
|
||||
basicFormula: feeResolved.basicFormula,
|
||||
basicFee_basic: toMoney(basicFeeBasic),
|
||||
optionalFormula: feeResolved.optionalFormula,
|
||||
basicFee_optional: toMoney(basicFeeOptional),
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||||
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
|
||||
proportion: toFiniteNumber(row.workRatio) ?? 1,
|
||||
fee: toMoney(basicFee),
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod2Detail => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
proAmount: proSet.size > 0 ? proSet.size : 1,
|
||||
area: sumNumbers(det.map(item => item.area)),
|
||||
basicFee: toMoney(sumNumbers(det.map(item => item.basicFee))),
|
||||
basicFee_basic: toMoney(sumNumbers(det.map(item => item.basicFee_basic))),
|
||||
basicFee_optional: toMoney(sumNumbers(det.map(item => item.basicFee_optional))),
|
||||
fee: toMoney(sumNumbers(det.map(item => item.fee))),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3 | null => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const task = getTaskIdFromRowId(row.id)
|
||||
if (task == null || row.basicFee == null) return null
|
||||
const amount = toFiniteNumber(row.workload)
|
||||
const basicFee = toFiniteNumber(row.basicFee)
|
||||
const fee = toFiniteNumber(row.serviceFee)
|
||||
if (fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue = amount != null || basicFee != null || fee != null || isNonEmptyString(remark)
|
||||
if (!hasValue) return null
|
||||
return {
|
||||
task,
|
||||
price: toFiniteNumberOrZero(row.budgetAdoptedUnitPrice),
|
||||
amount: amount ?? 0,
|
||||
basicFee: basicFee ?? 0,
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
fee: fee ?? 0,
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod3Detail => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
basicFee: sumNumbers(det.map(item => item.basicFee)),
|
||||
fee: sumNumbers(det.map(item => item.fee)),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 | null => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const expert = getExpertIdFromRowId(row.id)
|
||||
if (expert == null || row.serviceBudget == null) return null
|
||||
const personNum = toFiniteNumber(row.personnelCount)
|
||||
const workDay = toFiniteNumber(row.workdayCount)
|
||||
const fee = toFiniteNumber(row.serviceBudget)
|
||||
if (fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue = personNum != null || workDay != null || fee != null || isNonEmptyString(remark)
|
||||
if (!hasValue) return null
|
||||
return {
|
||||
expert,
|
||||
price: toFiniteNumberOrZero(row.adoptedBudgetUnitPrice),
|
||||
person_num: personNum ?? 0,
|
||||
work_day: workDay ?? 0,
|
||||
fee: fee ?? 0,
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod4Detail => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
person_num: sumNumbers(det.map(item => item.person_num)),
|
||||
work_day: sumNumbers(det.map(item => item.work_day)),
|
||||
fee: sumNumbers(det.map(item => item.fee)),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
const buildServiceFee = (
|
||||
row: ZxFwRowLike | null | undefined,
|
||||
method1: ExportMethod1 | null,
|
||||
method2: ExportMethod2 | null,
|
||||
method3: ExportMethod3 | null,
|
||||
method4: ExportMethod4 | null
|
||||
) => {
|
||||
const subtotal = toFiniteNumber(row?.subtotal)
|
||||
if (subtotal != null) return subtotal
|
||||
|
||||
const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
|
||||
if (methodSum !== 0) return methodSum
|
||||
|
||||
return sumNumbers([
|
||||
toFiniteNumber(row?.investScale),
|
||||
toFiniteNumber(row?.landScale),
|
||||
toFiniteNumber(row?.workload),
|
||||
toFiniteNumber(row?.hourly)
|
||||
const loadFactorRowsState = async (storageKey: string) => {
|
||||
const [piniaData, kvData] = await Promise.all([
|
||||
zxFwPricingStore.loadKeyState<DetailRowsStorageLike<FactorRowLike>>(storageKey),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(storageKey)
|
||||
])
|
||||
return {
|
||||
piniaData,
|
||||
kvData,
|
||||
resolved: piniaData || kvData || null
|
||||
}
|
||||
}
|
||||
|
||||
const createRichTextCode = (...parts: string[]): unknown => ({
|
||||
@ -1446,50 +1066,6 @@ const createRichTextCode = (...parts: string[]): unknown => ({
|
||||
.map(text => ({ text }))
|
||||
})
|
||||
|
||||
const buildMethod0 = (payload: RateMethodRowLike | null | undefined): ExportMethod0 | null => {
|
||||
const coe = toFiniteNumber(payload?.rate)
|
||||
const fee = toFiniteNumber(payload?.budgetFee)
|
||||
if (fee == null) return null
|
||||
return {
|
||||
coe: coe ?? 0,
|
||||
fee
|
||||
}
|
||||
}
|
||||
|
||||
const buildMethod5 = (rows: QuantityMethodRowLike[] | undefined): ExportMethod5 | null => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null
|
||||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||||
const subtotalFee = toFiniteNumber(subtotalRow?.budgetFee)
|
||||
if (subtotalFee == null ) return null
|
||||
const det = rows
|
||||
.filter(row => String(row?.id || '') !== 'fee-subtotal-fixed')
|
||||
.map(row => {
|
||||
const quantity = toFiniteNumber(row.quantity)
|
||||
const unitPrice = toFiniteNumber(row.unitPrice)
|
||||
const fee = toFiniteNumber(row.budgetFee)
|
||||
const name = typeof row.feeItem === 'string' ? row.feeItem : ''
|
||||
const unit = typeof row.unit === 'string' ? row.unit : ''
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
|
||||
if (row.budgetFee==null) return null
|
||||
return {
|
||||
name,
|
||||
unit,
|
||||
amount: quantity ,
|
||||
price: unitPrice ,
|
||||
fee: fee ,
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod5Detail => Boolean(item))
|
||||
|
||||
if (det.length === 0) return null
|
||||
return {
|
||||
fee: subtotalFee,
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string }>
|
||||
return rows
|
||||
@ -1538,23 +1114,6 @@ const buildAdditionalRowTasks = async (contractId: string, rowId: string): Promi
|
||||
return groupWorkContentTasks(taskState?.detailRows, { forceUngroup: true })
|
||||
}
|
||||
|
||||
const buildServiceFinalFee = (
|
||||
row: ZxFwRowLike | null | undefined,
|
||||
method1: ExportMethod1 | null,
|
||||
method2: ExportMethod2 | null,
|
||||
method3: ExportMethod3 | null,
|
||||
method4: ExportMethod4 | null
|
||||
) => {
|
||||
const finalFee = toFiniteNumber(row?.finalFee)
|
||||
if (finalFee != null) return finalFee
|
||||
const subtotal = toFiniteNumber(row?.subtotal)
|
||||
if (subtotal != null) return subtotal
|
||||
const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
|
||||
return methodSum
|
||||
}
|
||||
|
||||
|
||||
|
||||
const buildAdditionalExport = async (contractId: string): Promise<ExportAdditional | null> => {
|
||||
const storageKey = `htExtraFee-${contractId}-additional-work`
|
||||
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(storageKey)
|
||||
@ -1616,11 +1175,11 @@ const buildReserveExport = async (contractId: string): Promise<ExportReserve | n
|
||||
}
|
||||
|
||||
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorRaw, majorFactorRaw, contractCardsRaw] = await Promise.all([
|
||||
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorState, majorFactorState, contractCardsRaw] = await Promise.all([
|
||||
kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
|
||||
kvStore.getItem<XmInfoStorageLike>(LEGACY_PROJECT_DB_KEY),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(CONSULT_CATEGORY_FACTOR_DB_KEY),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(MAJOR_FACTOR_DB_KEY),
|
||||
loadFactorRowsState(CONSULT_CATEGORY_FACTOR_DB_KEY),
|
||||
loadFactorRowsState(MAJOR_FACTOR_DB_KEY),
|
||||
kvStore.getItem<ContractCardItem[]>('ht-card-v1')
|
||||
])
|
||||
|
||||
@ -1630,12 +1189,13 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const projectScale = projectScaleSource.roughCalcEnabled ? [] : toExportScaleRows(projectScaleSource.detailRows)
|
||||
const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumNumbers(projectScale.map(item => item.cost))
|
||||
projectScale.push({
|
||||
majorid: -1,
|
||||
major: -1, cost: projectScaleCost,
|
||||
area: null
|
||||
})
|
||||
|
||||
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorRaw?.detailRows)
|
||||
const projectMajorCoes = buildProjectMajorCoes(majorFactorRaw?.detailRows)
|
||||
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorState.resolved?.detailRows)
|
||||
const projectMajorCoes = buildProjectMajorCoes(majorFactorState.resolved?.detailRows)
|
||||
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目'
|
||||
const writer = isNonEmptyString(projectInfo.preparedBy) ? projectInfo.preparedBy.trim() : ''
|
||||
const reviewer = isNonEmptyString(projectInfo.reviewedBy) ? projectInfo.reviewedBy.trim() : ''
|
||||
@ -1655,11 +1215,11 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const contract = contractCards[index]
|
||||
const contractId = contract.id
|
||||
await zxFwPricingStore.loadContract(contractId)
|
||||
const [htInfoRaw, zxFwRaw, htConsultCategoryFactorRaw, htMajorFactorRaw, htBaseInfoRaw] = await Promise.all([
|
||||
const [htInfoRaw, zxFwRaw, htConsultCategoryFactorState, htMajorFactorState, htBaseInfoRaw] = await Promise.all([
|
||||
kvStore.getItem<DetailRowsStorageLike<ScaleRowLike>>(`ht-info-v3-${contractId}`),
|
||||
kvStore.getItem<ZxFwStorageLike>(`zxFW-${contractId}`),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(`ht-consult-category-factor-v1-${contractId}`),
|
||||
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(`ht-major-factor-v1-${contractId}`),
|
||||
loadFactorRowsState(`ht-consult-category-factor-v1-${contractId}`),
|
||||
loadFactorRowsState(`ht-major-factor-v1-${contractId}`),
|
||||
zxFwPricingStore.loadKeyState<HtBaseInfoLike>(`ht-base-info-${contractId}`)
|
||||
])
|
||||
|
||||
@ -1766,11 +1326,23 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
|
||||
const contractFee = roundTo(addNumbers(serviceFee, addtionalFee, reserveFee), 3)
|
||||
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
|
||||
contractScale.push({
|
||||
majorid: -1,
|
||||
major: -1, cost: contractFee,
|
||||
area: null
|
||||
})
|
||||
const contractServiceCoesRaw = buildProjectServiceCoes(htConsultCategoryFactorRaw?.detailRows)
|
||||
const contractMajorCoesRaw = buildProjectMajorCoes(htMajorFactorRaw?.detailRows)
|
||||
const contractServiceCoesRaw = buildProjectServiceCoes(htConsultCategoryFactorState.resolved?.detailRows)
|
||||
const contractMajorCoesRaw = buildProjectMajorCoes(htMajorFactorState.resolved?.detailRows)
|
||||
console.log('[export][contract factor rows]', {
|
||||
contractId,
|
||||
consultFactorPinia: htConsultCategoryFactorState.piniaData,
|
||||
consultFactorKv: htConsultCategoryFactorState.kvData,
|
||||
consultFactorResolved: htConsultCategoryFactorState.resolved,
|
||||
majorFactorPinia: htMajorFactorState.piniaData,
|
||||
majorFactorKv: htMajorFactorState.kvData,
|
||||
majorFactorResolved: htMajorFactorState.resolved,
|
||||
contractServiceCoesRaw,
|
||||
contractMajorCoesRaw
|
||||
})
|
||||
|
||||
contracts.push({
|
||||
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`,
|
||||
|
||||
141
src/lib/contractSegment.ts
Normal file
141
src/lib/contractSegment.ts
Normal file
@ -0,0 +1,141 @@
|
||||
export interface DataEntry {
|
||||
key: string
|
||||
value: any
|
||||
}
|
||||
|
||||
export interface ContractSegmentPackage {
|
||||
version: number
|
||||
exportedAt: string
|
||||
packageType?: 'contract-segments'
|
||||
project?: {
|
||||
industry: string
|
||||
}
|
||||
storage?: {
|
||||
localforageEntries: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
}
|
||||
contracts: Array<{
|
||||
id: string
|
||||
name: string
|
||||
order: number
|
||||
createdAt: string
|
||||
}>
|
||||
projectIndustry?: string
|
||||
localforageEntries?: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
pinia?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
servicePricingStates?: Record<string, unknown>
|
||||
htFeeMainStates?: Record<string, unknown>
|
||||
htFeeMethodStates?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
piniaState?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
servicePricingStates?: Record<string, unknown>
|
||||
htFeeMainStates?: Record<string, unknown>
|
||||
htFeeMethodStates?: Record<string, unknown>
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
|
||||
export const CONTRACT_SEGMENT_VERSION = 3
|
||||
export const CONTRACT_KEY_PREFIX = 'ht-info-v3-'
|
||||
export const SERVICE_KEY_PREFIX = 'zxFW-'
|
||||
export const CONTRACT_CONSULT_FACTOR_KEY_PREFIX = 'ht-consult-category-factor-v1-'
|
||||
export const CONTRACT_MAJOR_FACTOR_KEY_PREFIX = 'ht-major-factor-v1-'
|
||||
export const PRICING_KEY_PREFIXES = ['tzGMF-', 'ydGMF-', 'gzlF-', 'hourlyPricing-', 'htExtraFee-'] as const
|
||||
export const PROJECT_INFO_KEY = 'xm-base-info-v1'
|
||||
export const PROJECT_SCALE_KEY = 'xm-info-v3'
|
||||
export const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly'] as const
|
||||
|
||||
export const formatExportTimestamp = (date: Date): string => {
|
||||
const yyyy = date.getFullYear()
|
||||
const mm = String(date.getMonth() + 1).padStart(2, '0')
|
||||
const dd = String(date.getDate()).padStart(2, '0')
|
||||
const hh = String(date.getHours()).padStart(2, '0')
|
||||
const mi = String(date.getMinutes()).padStart(2, '0')
|
||||
return `${yyyy}${mm}${dd}-${hh}${mi}`
|
||||
}
|
||||
|
||||
export const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
||||
|
||||
export const cloneJson = <T>(value: T): T => JSON.parse(JSON.stringify(value)) as T
|
||||
|
||||
export const normalizeDataEntries = (value: unknown): DataEntry[] => {
|
||||
if (!Array.isArray(value)) return []
|
||||
return value
|
||||
.filter(item => item && typeof item === 'object' && typeof (item as DataEntry).key === 'string')
|
||||
.map(item => ({
|
||||
key: String((item as DataEntry).key),
|
||||
value: (item as DataEntry).value
|
||||
}))
|
||||
}
|
||||
|
||||
export const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
|
||||
projectIndustry:
|
||||
typeof payload.project?.industry === 'string' && payload.project.industry.trim()
|
||||
? payload.project.industry.trim()
|
||||
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
||||
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
||||
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
|
||||
piniaState: payload.pinia ?? payload.piniaState
|
||||
})
|
||||
|
||||
export const isContractSegmentPackage = (value: unknown): value is ContractSegmentPackage => {
|
||||
const payload = value as Partial<ContractSegmentPackage> | null
|
||||
return Boolean(payload && typeof payload === 'object' && Array.isArray(payload.contracts))
|
||||
}
|
||||
|
||||
export const isContractRelatedForageKey = (key: string, contractId: string) => {
|
||||
if (key === `${CONTRACT_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${SERVICE_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${contractId}`) return true
|
||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${contractId}`) return true
|
||||
if (PRICING_KEY_PREFIXES.some(prefix => key.startsWith(`${prefix}${contractId}-`))) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const isContractRelatedKeyedStateKey = (key: string, contractId: string) => {
|
||||
if (key === `ht-base-info-${contractId}`) return true
|
||||
if (key.startsWith(`work-content-${contractId}-`)) return true
|
||||
if (key.startsWith(`work-content-htExtraFee-${contractId}-`)) return true
|
||||
return false
|
||||
}
|
||||
|
||||
export const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
|
||||
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
|
||||
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
|
||||
if (key === `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${fromId}`) {
|
||||
return `${CONTRACT_CONSULT_FACTOR_KEY_PREFIX}${toId}`
|
||||
}
|
||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
|
||||
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
|
||||
}
|
||||
if (key === `ht-base-info-${fromId}`) return `ht-base-info-${toId}`
|
||||
if (key.startsWith(`work-content-${fromId}-`)) {
|
||||
return key.replace(`work-content-${fromId}-`, `work-content-${toId}-`)
|
||||
}
|
||||
if (key.startsWith(`work-content-htExtraFee-${fromId}-`)) {
|
||||
return key.replace(`work-content-htExtraFee-${fromId}-`, `work-content-htExtraFee-${toId}-`)
|
||||
}
|
||||
for (const prefix of PRICING_KEY_PREFIXES) {
|
||||
if (key.startsWith(`${prefix}${fromId}-`)) {
|
||||
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
|
||||
}
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
export const generateContractId = (usedIds: Set<string>) => {
|
||||
let nextId = ''
|
||||
while (!nextId || usedIds.has(nextId)) {
|
||||
nextId = `ct-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`
|
||||
}
|
||||
usedIds.add(nextId)
|
||||
return nextId
|
||||
}
|
||||
@ -1,5 +1,4 @@
|
||||
import Decimal from 'decimal.js'
|
||||
import { isFiniteNumber } from '@/lib/number'
|
||||
|
||||
type MaybeNumber = number | null | undefined
|
||||
type DecimalInput = Decimal.Value
|
||||
@ -9,6 +8,9 @@ export const toDecimal = (value: DecimalInput) => new Decimal(value)
|
||||
export const roundTo = (value: DecimalInput, decimalPlaces = 2) =>
|
||||
new Decimal(value).toDecimalPlaces(decimalPlaces, Decimal.ROUND_HALF_UP).toNumber()
|
||||
|
||||
const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
const sumFiniteValues = (values: Iterable<unknown>) => {
|
||||
let total = new Decimal(0)
|
||||
for (const value of values) {
|
||||
@ -41,3 +43,133 @@ export const decimalAggSum = (params: { values?: unknown[] }) => {
|
||||
if (!hasFinite) return null
|
||||
return sumFiniteValues(values)
|
||||
}
|
||||
|
||||
class DecimalExpressionParser {
|
||||
private readonly source: string
|
||||
private index = 0
|
||||
|
||||
constructor(source: string) {
|
||||
this.source = source
|
||||
}
|
||||
|
||||
parse(): Decimal | null {
|
||||
const value = this.parseExpression()
|
||||
this.skipWhitespace()
|
||||
if (!value || this.index !== this.source.length) return null
|
||||
return value
|
||||
}
|
||||
|
||||
private parseExpression(): Decimal | null {
|
||||
let value = this.parseTerm()
|
||||
if (!value) return null
|
||||
|
||||
while (true) {
|
||||
this.skipWhitespace()
|
||||
const operator = this.peek()
|
||||
if (operator !== '+' && operator !== '-') return value
|
||||
this.index += 1
|
||||
const right = this.parseTerm()
|
||||
if (!right) return null
|
||||
value = operator === '+' ? value.plus(right) : value.minus(right)
|
||||
}
|
||||
}
|
||||
|
||||
private parseTerm(): Decimal | null {
|
||||
let value = this.parseFactor()
|
||||
if (!value) return null
|
||||
|
||||
while (true) {
|
||||
this.skipWhitespace()
|
||||
const operator = this.peek()
|
||||
if (operator !== '*' && operator !== '/') return value
|
||||
this.index += 1
|
||||
const right = this.parseFactor()
|
||||
if (!right) return null
|
||||
if (operator === '/') {
|
||||
if (right.isZero()) return null
|
||||
value = value.div(right)
|
||||
continue
|
||||
}
|
||||
value = value.mul(right)
|
||||
}
|
||||
}
|
||||
|
||||
private parseFactor(): Decimal | null {
|
||||
this.skipWhitespace()
|
||||
const current = this.peek()
|
||||
if (current === '+') {
|
||||
this.index += 1
|
||||
return this.parseFactor()
|
||||
}
|
||||
if (current === '-') {
|
||||
this.index += 1
|
||||
const value = this.parseFactor()
|
||||
return value ? value.neg() : null
|
||||
}
|
||||
if (current === '(') {
|
||||
this.index += 1
|
||||
const value = this.parseExpression()
|
||||
this.skipWhitespace()
|
||||
if (!value || this.peek() !== ')') return null
|
||||
this.index += 1
|
||||
return value
|
||||
}
|
||||
return this.parseNumber()
|
||||
}
|
||||
|
||||
private parseNumber(): Decimal | null {
|
||||
this.skipWhitespace()
|
||||
const start = this.index
|
||||
let hasDigit = false
|
||||
let hasDot = false
|
||||
|
||||
while (this.index < this.source.length) {
|
||||
const char = this.source[this.index]
|
||||
if (char >= '0' && char <= '9') {
|
||||
hasDigit = true
|
||||
this.index += 1
|
||||
continue
|
||||
}
|
||||
if (char === '.' && !hasDot) {
|
||||
hasDot = true
|
||||
this.index += 1
|
||||
continue
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
if (!hasDigit) {
|
||||
this.index = start
|
||||
return null
|
||||
}
|
||||
|
||||
const literal = this.source.slice(start, this.index)
|
||||
try {
|
||||
return new Decimal(literal)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private skipWhitespace() {
|
||||
while (this.index < this.source.length && /\s/.test(this.source[this.index])) {
|
||||
this.index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private peek() {
|
||||
return this.source[this.index]
|
||||
}
|
||||
}
|
||||
|
||||
// 支持 + - * / () 的高精度表达式计算,用于数字输入框和表格 valueParser。
|
||||
export const evaluateDecimalExpression = (value: string): number | null => {
|
||||
const trimmed = String(value || '').trim()
|
||||
if (!trimmed) return null
|
||||
try {
|
||||
const parsed = new DecimalExpressionParser(trimmed).parse()
|
||||
return parsed ? parsed.toNumber() : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
import type { GridOptions } from 'ag-grid-community'
|
||||
import type {
|
||||
CellPosition,
|
||||
ColDef,
|
||||
GridOptions,
|
||||
SuppressKeyboardEventParams
|
||||
} from 'ag-grid-community'
|
||||
import { themeQuartz } from 'ag-grid-community'
|
||||
|
||||
const borderConfig = {
|
||||
@ -26,6 +31,75 @@ export const agGridWrapClass = 'ag-theme-quartz h-full min-h-0 w-full flex-1'
|
||||
// AG Grid 组件通用 style(撑满容器 div)
|
||||
export const agGridStyle = { height: '100%' }
|
||||
|
||||
const isPlainEnterKey = (event: KeyboardEvent) =>
|
||||
event.key === 'Enter' && !event.altKey && !event.ctrlKey && !event.metaKey
|
||||
|
||||
const findNextEditableCellInColumn = (
|
||||
params: SuppressKeyboardEventParams,
|
||||
startRowIndex: number
|
||||
): CellPosition | null => {
|
||||
const column = params.column
|
||||
for (let rowIndex = startRowIndex + 1; rowIndex < params.api.getDisplayedRowCount(); rowIndex += 1) {
|
||||
const rowNode = params.api.getDisplayedRowAtIndex(rowIndex)
|
||||
if (!rowNode || rowNode.group || rowNode.rowPinned) continue
|
||||
if (!column.isCellEditable(rowNode)) continue
|
||||
return {
|
||||
rowIndex,
|
||||
rowPinned: rowNode.rowPinned ?? null,
|
||||
column
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const focusCellPosition = (
|
||||
params: SuppressKeyboardEventParams,
|
||||
cellPosition: CellPosition | null
|
||||
) => {
|
||||
const target = cellPosition || {
|
||||
rowIndex: params.node.rowIndex ?? 0,
|
||||
rowPinned: params.node.rowPinned ?? null,
|
||||
column: params.column
|
||||
}
|
||||
window.setTimeout(() => {
|
||||
if (params.api.isDestroyed?.()) return
|
||||
params.api.ensureIndexVisible(target.rowIndex)
|
||||
params.api.setFocusedCell(target.rowIndex, target.column, target.rowPinned)
|
||||
}, 0)
|
||||
}
|
||||
|
||||
const suppressExcelLikeEnter = (params: SuppressKeyboardEventParams) => {
|
||||
if (!isPlainEnterKey(params.event)) return false
|
||||
if (params.event.defaultPrevented || params.event.isComposing) return false
|
||||
|
||||
params.event.preventDefault()
|
||||
params.event.stopPropagation()
|
||||
params.api.stopEditing()
|
||||
|
||||
const currentRowIndex = params.node.rowIndex
|
||||
if (currentRowIndex == null) {
|
||||
focusCellPosition(params, null)
|
||||
return true
|
||||
}
|
||||
|
||||
const nextCell = findNextEditableCellInColumn(params, currentRowIndex)
|
||||
focusCellPosition(params, nextCell)
|
||||
return true
|
||||
}
|
||||
|
||||
export const agGridDefaultColDef: ColDef = {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
wrapHeaderText: true,
|
||||
autoHeaderHeight: true,
|
||||
suppressKeyboardEvent: suppressExcelLikeEnter,
|
||||
// 默认把数值型单元格右对齐,减少每个列重复配置。
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': params => typeof params.value === 'number' && Number.isFinite(params.value)
|
||||
}
|
||||
}
|
||||
|
||||
export const gridOptions: GridOptions = {
|
||||
treeData: true,
|
||||
animateRows: true,
|
||||
@ -59,17 +133,7 @@ export const gridOptions: GridOptions = {
|
||||
return [fallback || '__row__']
|
||||
},
|
||||
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
|
||||
defaultColDef: {
|
||||
resizable: true,
|
||||
sortable: false,
|
||||
filter: false,
|
||||
wrapHeaderText: true,
|
||||
autoHeaderHeight: true,
|
||||
// 默认把数值型单元格右对齐,减少每个列重复配置。
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': params => typeof params.value === 'number' && Number.isFinite(params.value)
|
||||
}
|
||||
},
|
||||
defaultColDef: agGridDefaultColDef,
|
||||
defaultColGroupDef: {
|
||||
wrapHeaderText: true,
|
||||
autoHeaderHeight: true
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import { evaluateDecimalExpression, roundTo } from '@/lib/decimal'
|
||||
|
||||
export const isFiniteNumber = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
|
||||
@ -10,12 +12,23 @@ export const parseNumberOrNull = (
|
||||
): number | null => {
|
||||
if (value === '' || value == null) return null
|
||||
|
||||
const normalized =
|
||||
const normalizedValue =
|
||||
options?.sanitize && typeof value === 'string'
|
||||
? value.replace(/[^0-9.\-]/g, '')
|
||||
? value.replace(/[^0-9.+\-*/()\s]/g, '')
|
||||
: value
|
||||
|
||||
const numericValue = Number(normalized)
|
||||
if (normalizedValue === '' || normalizedValue == null) return null
|
||||
|
||||
const normalized =
|
||||
typeof normalizedValue === 'string' ? normalizedValue.trim() : normalizedValue
|
||||
if (normalized === '') return null
|
||||
|
||||
let numericValue = Number(normalized)
|
||||
if (!Number.isFinite(numericValue) && typeof normalized === 'string') {
|
||||
const evaluated = evaluateDecimalExpression(normalized)
|
||||
if (evaluated == null || !Number.isFinite(evaluated)) return null
|
||||
numericValue = evaluated
|
||||
}
|
||||
if (!Number.isFinite(numericValue)) return null
|
||||
|
||||
const precision = options?.precision
|
||||
@ -23,8 +36,5 @@ export const parseNumberOrNull = (
|
||||
return numericValue
|
||||
}
|
||||
|
||||
const factor = 10 ** precision
|
||||
if (!Number.isFinite(factor) || factor <= 0) return numericValue
|
||||
if (numericValue >= 0) return Math.round((numericValue + Number.EPSILON) * factor) / factor
|
||||
return -Math.round((-numericValue + Number.EPSILON) * factor) / factor
|
||||
return roundTo(numericValue, precision)
|
||||
}
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
} from '@/sql'
|
||||
import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal'
|
||||
import { toFiniteNumberOrNull } from '@/lib/number'
|
||||
import { getBenchmarkBudgetByScale, getBenchmarkBudgetSplitByScale, getScaleBudgetFee, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
import { getScaleBudgetFee } from '@/lib/pricingScaleFee'
|
||||
import { getScaleBudgetFeeByRow } from '@/lib/pricingScaleDetail'
|
||||
import { useZxFwPricingStore, type ServicePricingMethod } from '@/pinia/zxFwPricing'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
@ -264,10 +265,12 @@ const resolveFactorValue = (
|
||||
fallback: number | null
|
||||
) => {
|
||||
if (!row) return fallback
|
||||
const budgetValue = toFiniteNumberOrNull(row.budgetValue)
|
||||
if (budgetValue != null) return budgetValue
|
||||
const standardFactor = toFiniteNumberOrNull(row.standardFactor)
|
||||
if (standardFactor != null) return standardFactor
|
||||
if (hasOwn(row, 'budgetValue')) {
|
||||
return toFiniteNumberOrNull(row.budgetValue)
|
||||
}
|
||||
if (hasOwn(row, 'standardFactor')) {
|
||||
return toFiniteNumberOrNull(row.standardFactor)
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
@ -379,39 +382,7 @@ const mergeScaleRows = (
|
||||
})
|
||||
}
|
||||
|
||||
const getBenchmarkBudgetByAmount = (amount: MaybeNumber) =>
|
||||
getBenchmarkBudgetByScale(amount, 'cost')
|
||||
|
||||
const getBenchmarkBudgetByLandArea = (landArea: MaybeNumber) =>
|
||||
getBenchmarkBudgetByScale(landArea, 'area')
|
||||
|
||||
const getCheckedScaleBudgetSplit = (
|
||||
value: MaybeNumber,
|
||||
mode: 'cost' | 'area',
|
||||
row: Pick<ScaleRow, 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
|
||||
) => {
|
||||
const split = getBenchmarkBudgetSplitByScale(value, mode)
|
||||
if (!split) return null
|
||||
const basic = row.benchmarkBudgetBasicChecked === false ? 0 : split.basic
|
||||
const optional = row.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
|
||||
return {
|
||||
basic,
|
||||
optional
|
||||
}
|
||||
}
|
||||
|
||||
const getInvestmentBudgetFee = (row: ScaleRow) => {
|
||||
const split = getCheckedScaleBudgetSplit(row.amount, 'cost', row)
|
||||
if (!split) return null
|
||||
return getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic: split.basic,
|
||||
benchmarkBudgetOptional: split.optional,
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})?.total ?? null
|
||||
}
|
||||
const getInvestmentBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'cost')
|
||||
|
||||
const getOnlyCostScaleBudgetFee = (
|
||||
serviceId: string,
|
||||
@ -439,19 +410,15 @@ const getOnlyCostScaleBudgetFee = (
|
||||
return sumByNumberNullable(sourceRows, row => {
|
||||
const amount = toFiniteNumberOrNull(row?.amount)
|
||||
if (amount == null) return null
|
||||
const split = getCheckedScaleBudgetSplit(amount, 'cost', {
|
||||
return getScaleBudgetFeeByRow({
|
||||
amount,
|
||||
benchmarkBudgetBasicChecked: typeof row?.benchmarkBudgetBasicChecked === 'boolean' ? row.benchmarkBudgetBasicChecked : true,
|
||||
benchmarkBudgetOptionalChecked: typeof row?.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true
|
||||
})
|
||||
if (!split) return null
|
||||
return getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic: split.basic,
|
||||
benchmarkBudgetOptional: split.optional,
|
||||
benchmarkBudgetOptionalChecked: typeof row?.benchmarkBudgetOptionalChecked === 'boolean' ? row.benchmarkBudgetOptionalChecked : true,
|
||||
majorFactor: getRowNumberOrFallback(row, 'majorFactor', defaultMajorFactor),
|
||||
consultCategoryFactor: getRowNumberOrFallback(row, 'consultCategoryFactor', defaultConsultCategoryFactor),
|
||||
workStageFactor: getRowNumberOrFallback(row, 'workStageFactor', 1),
|
||||
workRatio: getRowNumberOrFallback(row, 'workRatio', 100)
|
||||
})?.total ?? null
|
||||
}, 'cost')
|
||||
})
|
||||
}
|
||||
|
||||
@ -467,19 +434,15 @@ const getOnlyCostScaleBudgetFee = (
|
||||
const majorFactor = getRowNumberOrFallback(onlyRow, 'majorFactor', defaultMajorFactor)
|
||||
const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1)
|
||||
const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100)
|
||||
const split = getCheckedScaleBudgetSplit(totalAmount, 'cost', {
|
||||
return getScaleBudgetFeeByRow({
|
||||
amount: totalAmount,
|
||||
benchmarkBudgetBasicChecked: typeof onlyRow?.benchmarkBudgetBasicChecked === 'boolean' ? onlyRow.benchmarkBudgetBasicChecked : true,
|
||||
benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true
|
||||
})
|
||||
if (!split) return null
|
||||
return getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic: split.basic,
|
||||
benchmarkBudgetOptional: split.optional,
|
||||
benchmarkBudgetOptionalChecked: typeof onlyRow?.benchmarkBudgetOptionalChecked === 'boolean' ? onlyRow.benchmarkBudgetOptionalChecked : true,
|
||||
majorFactor,
|
||||
consultCategoryFactor,
|
||||
workStageFactor,
|
||||
workRatio
|
||||
})?.total ?? null
|
||||
}, 'cost')
|
||||
}
|
||||
|
||||
const buildOnlyCostScaleDetailRows = (
|
||||
@ -528,18 +491,7 @@ const buildOnlyCostScaleDetailRows = (
|
||||
]
|
||||
}
|
||||
|
||||
const getLandBudgetFee = (row: ScaleRow) => {
|
||||
const split = getCheckedScaleBudgetSplit(row.landArea, 'area', row)
|
||||
if (!split) return null
|
||||
return getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic: split.basic,
|
||||
benchmarkBudgetOptional: split.optional,
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})?.total ?? null
|
||||
}
|
||||
const getLandBudgetFee = (row: ScaleRow) => getScaleBudgetFeeByRow(row, 'area')
|
||||
|
||||
const getTaskEntriesByServiceId = (serviceId: string | number) =>
|
||||
Object.entries(taskList as Record<string, TaskLite>)
|
||||
|
||||
64
src/lib/pricingPinnedRows.ts
Normal file
64
src/lib/pricingPinnedRows.ts
Normal file
@ -0,0 +1,64 @@
|
||||
export interface ScalePinnedTotalRowBase {
|
||||
id: string
|
||||
groupCode: string
|
||||
groupName: string
|
||||
majorCode: string
|
||||
majorName: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
amount?: number | null
|
||||
landArea?: number | null
|
||||
benchmarkBudget: number | null
|
||||
benchmarkBudgetBasic: number | null
|
||||
benchmarkBudgetOptional: number | null
|
||||
benchmarkBudgetBasicChecked: boolean
|
||||
benchmarkBudgetOptionalChecked: boolean
|
||||
basicFormula: string | null
|
||||
optionalFormula: string | null
|
||||
consultCategoryFactor: number | null
|
||||
majorFactor: number | null
|
||||
workStageFactor: number | null
|
||||
workRatio: number | null
|
||||
budgetFee: number | null
|
||||
budgetFeeBasic: number | null
|
||||
budgetFeeOptional: number | null
|
||||
remark: string
|
||||
path: string[]
|
||||
}
|
||||
|
||||
export const createScalePinnedTotalRow = <TRow extends ScalePinnedTotalRowBase>(
|
||||
overrides: Partial<TRow> & Pick<TRow, 'budgetFee' | 'budgetFeeBasic' | 'budgetFeeOptional'>
|
||||
) => {
|
||||
const baseRow: ScalePinnedTotalRowBase = {
|
||||
id: 'pinned-total-row',
|
||||
groupCode: '',
|
||||
groupName: '',
|
||||
majorCode: '',
|
||||
majorName: '',
|
||||
hasCost: false,
|
||||
hasArea: false,
|
||||
amount: null,
|
||||
benchmarkBudget: null,
|
||||
benchmarkBudgetBasic: null,
|
||||
benchmarkBudgetOptional: null,
|
||||
benchmarkBudgetBasicChecked: true,
|
||||
benchmarkBudgetOptionalChecked: true,
|
||||
basicFormula: '',
|
||||
optionalFormula: '',
|
||||
consultCategoryFactor: null,
|
||||
majorFactor: null,
|
||||
workStageFactor: null,
|
||||
workRatio: null,
|
||||
budgetFee: null,
|
||||
budgetFeeBasic: null,
|
||||
budgetFeeOptional: null,
|
||||
remark: '',
|
||||
path: ['TOTAL']
|
||||
}
|
||||
return {
|
||||
...baseRow,
|
||||
...overrides
|
||||
} as TRow
|
||||
}
|
||||
|
||||
export const createPinnedTopRowData = <TRow>(row: TRow): TRow[] => [row]
|
||||
283
src/lib/pricingScaleColumns.ts
Normal file
283
src/lib/pricingScaleColumns.ts
Normal file
@ -0,0 +1,283 @@
|
||||
import type { ColDef, ColGroupDef } from 'ag-grid-community'
|
||||
import {
|
||||
formatScaleEditableNumber,
|
||||
formatScaleReadonlyMoney,
|
||||
getScaleMergeColSpanBeforeTotal
|
||||
} from '@/lib/pricingScaleGrid'
|
||||
|
||||
type ScaleColumnField<TRow> = Extract<keyof TRow, string> | string
|
||||
|
||||
export const createScaleValueColumn = <TRow>(options: {
|
||||
headerName: string
|
||||
field: ScaleColumnField<TRow>
|
||||
headerTooltip: string
|
||||
onReset: () => Promise<void> | void
|
||||
resetTitle: string
|
||||
headerComponent: any
|
||||
minWidth?: number
|
||||
flex?: number
|
||||
isEditable: (row: TRow | undefined) => boolean
|
||||
emptyTextPredicate: (row: TRow | undefined, value: unknown) => boolean
|
||||
valueParser: (params: any) => any
|
||||
valueFormatter: (params: any) => string
|
||||
}) : ColDef<TRow> => ({
|
||||
headerName: options.headerName,
|
||||
field: options.field as any,
|
||||
headerTooltip: options.headerTooltip,
|
||||
headerComponent: options.headerComponent,
|
||||
headerComponentParams: {
|
||||
onReset: options.onReset,
|
||||
resetTitle: options.resetTitle
|
||||
},
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: options.minWidth ?? 90,
|
||||
flex: options.flex ?? 2,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned && options.isEditable(params.data),
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned && options.isEditable(params.data)
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && options.emptyTextPredicate(params.data, params.value)
|
||||
},
|
||||
valueParser: options.valueParser,
|
||||
valueFormatter: options.valueFormatter
|
||||
})
|
||||
|
||||
export const createScaleBenchmarkBudgetColumnGroup = <TRow>(options: {
|
||||
getCheckedSplit: (row: TRow | undefined) => { basic?: number | null; optional?: number | null; total?: number | null } | null
|
||||
createBudgetCellRendererWithCheck: (field: 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked') => any
|
||||
}) : ColGroupDef<TRow> => ({
|
||||
headerName: '基准预算(元)',
|
||||
marryChildren: true,
|
||||
children: [
|
||||
{
|
||||
headerName: '基本工作',
|
||||
field: 'benchmarkBudgetBasic' as any,
|
||||
colId: 'benchmarkBudgetBasic',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.basic ?? null),
|
||||
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetBasicChecked'),
|
||||
cellRendererParams: {
|
||||
suppressMouseEventHandling: () => true
|
||||
},
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
},
|
||||
{
|
||||
headerName: '可选工作',
|
||||
field: 'benchmarkBudgetOptional' as any,
|
||||
colId: 'benchmarkBudgetOptional',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 130,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.optional ?? null),
|
||||
cellRenderer: options.createBudgetCellRendererWithCheck('benchmarkBudgetOptionalChecked'),
|
||||
cellRendererParams: {
|
||||
suppressMouseEventHandling: () => true
|
||||
},
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
},
|
||||
{
|
||||
headerName: '小计',
|
||||
field: 'benchmarkBudget' as any,
|
||||
colId: 'benchmarkBudgetTotal',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 100,
|
||||
flex: 1,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? null : options.getCheckedSplit(params.data)?.total ?? null),
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export const createScaleBudgetFeeColumnGroup = <TRow>(options: {
|
||||
headerComponent: any
|
||||
restoreConsultCategoryFactorColumnDefaults: () => Promise<void> | void
|
||||
restoreMajorFactorColumnDefaults: () => Promise<void> | void
|
||||
parseNumberOrNull: (value: any, options?: any) => any
|
||||
getBudgetFee: (row: TRow | undefined) => number | null
|
||||
aggFunc: any
|
||||
}) : ColGroupDef<TRow> => ({
|
||||
headerName: '预算费用',
|
||||
marryChildren: true,
|
||||
children: [
|
||||
{
|
||||
headerName: '咨询分类系数',
|
||||
field: 'consultCategoryFactor' as any,
|
||||
colId: 'consultCategoryFactor',
|
||||
headerTooltip: '点击右侧↻恢复本列默认咨询分类系数',
|
||||
headerComponent: options.headerComponent,
|
||||
headerComponentParams: {
|
||||
onReset: options.restoreConsultCategoryFactorColumnDefaults,
|
||||
resetTitle: '恢复本列默认咨询分类系数'
|
||||
},
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params)
|
||||
},
|
||||
{
|
||||
headerName: '专业系数',
|
||||
field: 'majorFactor' as any,
|
||||
colId: 'majorFactor',
|
||||
headerTooltip: '点击右侧↻恢复本列默认专业系数',
|
||||
headerComponent: options.headerComponent,
|
||||
headerComponentParams: {
|
||||
onReset: options.restoreMajorFactorColumnDefaults,
|
||||
resetTitle: '恢复本列默认专业系数'
|
||||
},
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params)
|
||||
},
|
||||
{
|
||||
headerName: '工作环节系数(编审系数)',
|
||||
field: 'workStageFactor' as any,
|
||||
colId: 'workStageFactor',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 3 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params)
|
||||
},
|
||||
{
|
||||
headerName: '工作占比(%)',
|
||||
field: 'workRatio' as any,
|
||||
colId: 'workRatio',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 80,
|
||||
flex: 1,
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
cellClass: params =>
|
||||
!params.node?.group && !params.node?.rowPinned
|
||||
? 'editable-cell-line'
|
||||
: '',
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true,
|
||||
'editable-cell-empty': params =>
|
||||
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
|
||||
},
|
||||
valueParser: params => options.parseNumberOrNull(params.newValue, { precision: 2 }),
|
||||
valueFormatter: params => formatScaleEditableNumber(params, 2)
|
||||
},
|
||||
{
|
||||
headerName: '合计',
|
||||
field: 'budgetFee' as any,
|
||||
colId: 'budgetFeeTotal',
|
||||
headerClass: 'ag-right-aligned-header',
|
||||
minWidth: 120,
|
||||
flex: 1,
|
||||
aggFunc: options.aggFunc,
|
||||
cellClassRules: {
|
||||
'ag-right-aligned-cell': () => true
|
||||
},
|
||||
valueGetter: params => (params.node?.rowPinned ? (params.data as any)?.budgetFee ?? null : options.getBudgetFee(params.data)),
|
||||
valueFormatter: formatScaleReadonlyMoney
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
export const createScaleRemarkColumn = <TRow>() : ColDef<TRow> => ({
|
||||
headerName: '说明',
|
||||
field: 'remark' as any,
|
||||
minWidth: 100,
|
||||
flex: 1.2,
|
||||
cellEditor: 'agLargeTextCellEditor',
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
cellStyle: { whiteSpace: 'normal', lineHeight: '1.4' },
|
||||
editable: params => !params.node?.group && !params.node?.rowPinned,
|
||||
valueFormatter: params => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !params.value) return '点击输入'
|
||||
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 && (params.value == null || params.value === '')
|
||||
}
|
||||
})
|
||||
|
||||
export const createScaleAutoGroupColumn = <TRow>(options: {
|
||||
totalLabel: string
|
||||
idLabelMap: Map<string, string>
|
||||
parseProjectIndexFromPathKey: (key: string) => number | null
|
||||
}) : ColDef<TRow> => ({
|
||||
headerName: '专业编码以及工程专业名称',
|
||||
minWidth: 250,
|
||||
flex: 2,
|
||||
cellRendererParams: {
|
||||
suppressCount: true
|
||||
},
|
||||
colSpan: getScaleMergeColSpanBeforeTotal,
|
||||
valueFormatter: params => {
|
||||
if (params.node?.rowPinned) {
|
||||
return options.totalLabel
|
||||
}
|
||||
const rowData = params.data as any
|
||||
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
|
||||
return `${rowData.majorCode} ${rowData.majorName}`
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
|
||||
if (projectIndex != null) return `项目${projectIndex}`
|
||||
return options.idLabelMap.get(nodeId) || nodeId
|
||||
},
|
||||
tooltipValueGetter: params => {
|
||||
if (params.node?.rowPinned) return options.totalLabel
|
||||
const rowData = params.data as any
|
||||
if (!params.node?.group && rowData?.majorCode && rowData?.majorName) {
|
||||
return `${rowData.majorCode} ${rowData.majorName}`
|
||||
}
|
||||
const nodeId = String(params.value || '')
|
||||
const projectIndex = options.parseProjectIndexFromPathKey(nodeId)
|
||||
if (projectIndex != null) return `项目${projectIndex}`
|
||||
return options.idLabelMap.get(nodeId) || nodeId
|
||||
}
|
||||
})
|
||||
164
src/lib/pricingScaleDetail.ts
Normal file
164
src/lib/pricingScaleDetail.ts
Normal file
@ -0,0 +1,164 @@
|
||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit, type ScaleFeeSplitResult } from '@/lib/pricingScaleFee'
|
||||
|
||||
export type ScaleMode = 'cost' | 'area'
|
||||
|
||||
export interface ScaleBudgetCheckRow {
|
||||
benchmarkBudgetBasicChecked?: boolean
|
||||
benchmarkBudgetOptionalChecked?: boolean
|
||||
}
|
||||
|
||||
export interface ScaleBudgetSourceRow extends ScaleBudgetCheckRow {
|
||||
amount?: number | null
|
||||
landArea?: number | null
|
||||
majorFactor?: number | null
|
||||
consultCategoryFactor?: number | null
|
||||
workStageFactor?: number | null
|
||||
workRatio?: number | null
|
||||
}
|
||||
|
||||
export interface ScaleDetailComputedRow extends ScaleBudgetSourceRow {
|
||||
benchmarkBudget?: number | null
|
||||
benchmarkBudgetBasic?: number | null
|
||||
benchmarkBudgetOptional?: number | null
|
||||
basicFormula?: string | null
|
||||
optionalFormula?: string | null
|
||||
budgetFee?: number | null
|
||||
budgetFeeBasic?: number | null
|
||||
budgetFeeOptional?: number | null
|
||||
}
|
||||
|
||||
export type CheckedScaleFeeSplitResult = Omit<ScaleFeeSplitResult, 'total'> & {
|
||||
total: number | null
|
||||
}
|
||||
|
||||
const getScaleValueByMode = (
|
||||
row: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea'> | undefined,
|
||||
mode: ScaleMode
|
||||
) => (mode === 'cost' ? row?.amount : row?.landArea)
|
||||
|
||||
export const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
|
||||
if (left == null && right == null) return true
|
||||
if (left == null || right == null) return false
|
||||
return roundTo(left, 6) === roundTo(right, 6)
|
||||
}
|
||||
|
||||
export const isSameNullableText = (left: string | null | undefined, right: string | null | undefined) =>
|
||||
String(left ?? '') === String(right ?? '')
|
||||
|
||||
export const isBenchmarkBudgetFullyUnchecked = (
|
||||
row?: Pick<ScaleBudgetCheckRow, 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>
|
||||
) => row?.benchmarkBudgetBasicChecked === false && row?.benchmarkBudgetOptionalChecked === false
|
||||
|
||||
export const getBenchmarkBudgetRawSplitByRow = (
|
||||
row: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea'> | undefined,
|
||||
mode: ScaleMode
|
||||
) => getBenchmarkBudgetSplitByScale(getScaleValueByMode(row, mode), mode)
|
||||
|
||||
export const getCheckedBenchmarkBudgetSplitByRow = (
|
||||
row?: Pick<ScaleBudgetSourceRow, 'amount' | 'landArea' | 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'>,
|
||||
mode: ScaleMode = 'cost'
|
||||
): CheckedScaleFeeSplitResult | null => {
|
||||
const split = getBenchmarkBudgetRawSplitByRow(row, mode)
|
||||
if (!split) return null
|
||||
const basic = row?.benchmarkBudgetBasicChecked === false ? 0 : split.basic
|
||||
const optional = row?.benchmarkBudgetOptionalChecked === false ? 0 : split.optional
|
||||
return {
|
||||
...split,
|
||||
basic,
|
||||
optional,
|
||||
total: isBenchmarkBudgetFullyUnchecked(row) ? null : roundTo(addNumbers(basic, optional), 2)
|
||||
}
|
||||
}
|
||||
|
||||
export const getScaleBudgetFeeSplitByRow = (
|
||||
row?: Pick<
|
||||
ScaleBudgetSourceRow,
|
||||
| 'amount'
|
||||
| 'landArea'
|
||||
| 'benchmarkBudgetBasicChecked'
|
||||
| 'benchmarkBudgetOptionalChecked'
|
||||
| 'majorFactor'
|
||||
| 'consultCategoryFactor'
|
||||
| 'workStageFactor'
|
||||
| 'workRatio'
|
||||
>,
|
||||
mode: ScaleMode = 'cost'
|
||||
) => {
|
||||
if (isBenchmarkBudgetFullyUnchecked(row)) return null
|
||||
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByRow(row, mode)
|
||||
if (!benchmarkBudgetSplit) return null
|
||||
return getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic: benchmarkBudgetSplit.basic,
|
||||
benchmarkBudgetOptional: benchmarkBudgetSplit.optional,
|
||||
majorFactor: row?.majorFactor,
|
||||
consultCategoryFactor: row?.consultCategoryFactor,
|
||||
workStageFactor: row?.workStageFactor,
|
||||
workRatio: row?.workRatio
|
||||
})
|
||||
}
|
||||
|
||||
export const getScaleBudgetFeeByRow = (
|
||||
row?: Pick<
|
||||
ScaleBudgetSourceRow,
|
||||
| 'amount'
|
||||
| 'landArea'
|
||||
| 'benchmarkBudgetBasicChecked'
|
||||
| 'benchmarkBudgetOptionalChecked'
|
||||
| 'majorFactor'
|
||||
| 'consultCategoryFactor'
|
||||
| 'workStageFactor'
|
||||
| 'workRatio'
|
||||
>,
|
||||
mode: ScaleMode = 'cost'
|
||||
) => getScaleBudgetFeeSplitByRow(row, mode)?.total ?? null
|
||||
|
||||
export const recomputeScaleDetailRow = <TRow extends ScaleDetailComputedRow>(
|
||||
row: TRow,
|
||||
mode: ScaleMode
|
||||
): TRow => {
|
||||
const benchmarkBudgetRawSplit = getBenchmarkBudgetRawSplitByRow(row, mode)
|
||||
const benchmarkBudgetSplit = getCheckedBenchmarkBudgetSplitByRow(row, mode)
|
||||
const budgetFeeSplit = getScaleBudgetFeeSplitByRow(row, mode)
|
||||
|
||||
return {
|
||||
...row,
|
||||
benchmarkBudget: benchmarkBudgetSplit?.total ?? null,
|
||||
benchmarkBudgetBasic: benchmarkBudgetSplit?.basic ?? null,
|
||||
benchmarkBudgetOptional: benchmarkBudgetSplit?.optional ?? null,
|
||||
basicFormula: row.benchmarkBudgetBasicChecked === false ? null : (benchmarkBudgetRawSplit?.basicFormula ?? ''),
|
||||
optionalFormula: row.benchmarkBudgetOptionalChecked === false ? null : (benchmarkBudgetRawSplit?.optionalFormula ?? ''),
|
||||
budgetFee: budgetFeeSplit?.total ?? null,
|
||||
budgetFeeBasic: budgetFeeSplit?.basic ?? null,
|
||||
budgetFeeOptional: budgetFeeSplit?.optional ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export const recomputeScaleDetailRowsInPlace = <TRow extends ScaleDetailComputedRow>(
|
||||
rows: TRow[],
|
||||
mode: ScaleMode
|
||||
) => {
|
||||
for (const row of rows) {
|
||||
Object.assign(row, recomputeScaleDetailRow(row, mode))
|
||||
}
|
||||
}
|
||||
|
||||
export const isSameScaleDetailRow = (
|
||||
left: ScaleDetailComputedRow,
|
||||
right: ScaleDetailComputedRow,
|
||||
mode: ScaleMode
|
||||
) => {
|
||||
const isSameScaleValue = mode === 'cost'
|
||||
? isSameNullableNumber(left.amount, right.amount)
|
||||
: isSameNullableNumber(left.landArea, right.landArea)
|
||||
|
||||
return isSameScaleValue
|
||||
&& isSameNullableNumber(left.benchmarkBudget, right.benchmarkBudget)
|
||||
&& isSameNullableNumber(left.benchmarkBudgetBasic, right.benchmarkBudgetBasic)
|
||||
&& isSameNullableNumber(left.benchmarkBudgetOptional, right.benchmarkBudgetOptional)
|
||||
&& isSameNullableText(left.basicFormula, right.basicFormula)
|
||||
&& isSameNullableText(left.optionalFormula, right.optionalFormula)
|
||||
&& isSameNullableNumber(left.budgetFee, right.budgetFee)
|
||||
&& isSameNullableNumber(left.budgetFeeBasic, right.budgetFeeBasic)
|
||||
&& isSameNullableNumber(left.budgetFeeOptional, right.budgetFeeOptional)
|
||||
}
|
||||
120
src/lib/pricingScaleDict.ts
Normal file
120
src/lib/pricingScaleDict.ts
Normal file
@ -0,0 +1,120 @@
|
||||
export interface ScaleDictLeaf {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
hasCost: boolean
|
||||
hasArea: boolean
|
||||
}
|
||||
|
||||
export interface ScaleDictGroup {
|
||||
id: string
|
||||
code: string
|
||||
name: string
|
||||
children: ScaleDictLeaf[]
|
||||
}
|
||||
|
||||
type MajorLite = {
|
||||
code: string
|
||||
name: string
|
||||
hasCost?: boolean
|
||||
hasArea?: boolean
|
||||
}
|
||||
|
||||
export const buildScaleDetailDict = (
|
||||
entries: Array<[string, MajorLite]>,
|
||||
includeLeaf: (params: { id: string; item: MajorLite; hasCost: boolean; hasArea: boolean }) => boolean
|
||||
): ScaleDictGroup[] => {
|
||||
const groupMap = new Map<string, ScaleDictGroup>()
|
||||
const groupOrder: string[] = []
|
||||
const codeLookup = new Map(entries.map(([key, item]) => [item.code, { id: key, code: item.code, name: item.name }]))
|
||||
|
||||
for (const [key, item] of entries) {
|
||||
const code = item.code
|
||||
const isGroup = !code.includes('-')
|
||||
if (isGroup) {
|
||||
if (!groupMap.has(code)) groupOrder.push(code)
|
||||
groupMap.set(code, {
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
children: []
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
const parentCode = code.split('-')[0]
|
||||
if (!groupMap.has(parentCode)) {
|
||||
const parent = codeLookup.get(parentCode)
|
||||
if (!groupOrder.includes(parentCode)) groupOrder.push(parentCode)
|
||||
groupMap.set(parentCode, {
|
||||
id: parent?.id || `group-${parentCode}`,
|
||||
code: parentCode,
|
||||
name: parent?.name || parentCode,
|
||||
children: []
|
||||
})
|
||||
}
|
||||
|
||||
const hasCost = item.hasCost !== false
|
||||
const hasArea = item.hasArea !== false
|
||||
if (!includeLeaf({ id: key, item, hasCost, hasArea })) continue
|
||||
|
||||
groupMap.get(parentCode)!.children.push({
|
||||
id: key,
|
||||
code,
|
||||
name: item.name,
|
||||
hasCost,
|
||||
hasArea
|
||||
})
|
||||
}
|
||||
|
||||
return groupOrder.map(code => groupMap.get(code)).filter((group): group is ScaleDictGroup => Boolean(group))
|
||||
}
|
||||
|
||||
export const buildScaleIdLabelMap = (detailDict: ScaleDictGroup[]) => {
|
||||
const idLabelMap = new Map<string, string>()
|
||||
for (const group of detailDict) {
|
||||
idLabelMap.set(group.id, `${group.code} ${group.name}`)
|
||||
for (const child of group.children) {
|
||||
idLabelMap.set(child.id, `${child.code} ${child.name}`)
|
||||
}
|
||||
}
|
||||
return idLabelMap
|
||||
}
|
||||
|
||||
export const buildScaleRowsFromDict = <TRow>(options: {
|
||||
detailDict: ScaleDictGroup[]
|
||||
projectCount: number
|
||||
activeIndustryCode: string
|
||||
isMajorInIndustryScope: (groupId: string, industryCode: string) => boolean
|
||||
buildScopedRowId: (projectIndex: number, majorId: string) => string
|
||||
buildProjectGroupPathKey: (projectIndex: number) => string
|
||||
isMutipleService: boolean
|
||||
createRow: (params: {
|
||||
projectIndex: number
|
||||
group: ScaleDictGroup
|
||||
child: ScaleDictLeaf
|
||||
rowId: string
|
||||
path: string[]
|
||||
}) => TRow
|
||||
}) => {
|
||||
if (!options.activeIndustryCode) return [] as TRow[]
|
||||
const rows: TRow[] = []
|
||||
for (let projectIndex = 1; projectIndex <= options.projectCount; projectIndex++) {
|
||||
for (const group of options.detailDict) {
|
||||
if (options.activeIndustryCode && !options.isMajorInIndustryScope(group.id, options.activeIndustryCode)) continue
|
||||
for (const child of group.children) {
|
||||
const rowId = options.buildScopedRowId(projectIndex, child.id)
|
||||
rows.push(options.createRow({
|
||||
projectIndex,
|
||||
group,
|
||||
child,
|
||||
rowId,
|
||||
path: options.isMutipleService
|
||||
? [options.buildProjectGroupPathKey(projectIndex), group.id, rowId]
|
||||
: [group.id, rowId]
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
168
src/lib/pricingScaleGrid.ts
Normal file
168
src/lib/pricingScaleGrid.ts
Normal file
@ -0,0 +1,168 @@
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import type { GridApi } from 'ag-grid-community'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export type ScaleBudgetCheckField = 'benchmarkBudgetBasicChecked' | 'benchmarkBudgetOptionalChecked'
|
||||
|
||||
type BudgetCheckRow = {
|
||||
id: string
|
||||
benchmarkBudgetBasicChecked: boolean
|
||||
benchmarkBudgetOptionalChecked: boolean
|
||||
benchmarkBudgetBasic?: number | null
|
||||
benchmarkBudgetOptional?: number | null
|
||||
}
|
||||
|
||||
export const formatScaleEditableNumber = (params: any, precision = 3, emptyText = '请输入') => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return emptyText
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, precision)
|
||||
}
|
||||
|
||||
export const formatScaleEditableConditionalNumber = (
|
||||
params: any,
|
||||
options: { enabled: boolean; precision?: number; emptyText?: string }
|
||||
) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned && !options.enabled) {
|
||||
return ''
|
||||
}
|
||||
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
|
||||
return options.emptyText ?? '点击输入'
|
||||
}
|
||||
if (params.value == null) return ''
|
||||
return formatThousandsFlexible(params.value, options.precision ?? 3)
|
||||
}
|
||||
|
||||
export const formatScaleReadonlyMoney = (params: any) => {
|
||||
if (params.value == null || params.value === '') return ''
|
||||
return formatThousandsFlexible(roundTo(params.value, 3), 3)
|
||||
}
|
||||
|
||||
export const updateScaleBudgetCheckState = <TRow extends BudgetCheckRow>(
|
||||
rows: TRow[],
|
||||
rowId: string,
|
||||
checkField: ScaleBudgetCheckField,
|
||||
checked: boolean
|
||||
) => {
|
||||
for (const row of rows) {
|
||||
if (row.id !== rowId) continue
|
||||
if (checkField === 'benchmarkBudgetBasicChecked') {
|
||||
row.benchmarkBudgetBasicChecked = checked
|
||||
row.benchmarkBudgetBasic = checked ? row.benchmarkBudgetBasic : 0
|
||||
return
|
||||
}
|
||||
row.benchmarkBudgetOptionalChecked = checked
|
||||
row.benchmarkBudgetOptional = checked ? row.benchmarkBudgetOptional : 0
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
export const createScaleBudgetCellRendererWithCheck = <TRow extends Record<string, any>>(
|
||||
checkField: ScaleBudgetCheckField,
|
||||
options: {
|
||||
formatValue: (params: any) => string
|
||||
onToggle: (row: TRow, checked: boolean) => void
|
||||
}
|
||||
) => (params: any) => {
|
||||
const valueText = options.formatValue(params)
|
||||
const hasValue = params.value != null && params.value !== ''
|
||||
if (params.node?.group || params.node?.rowPinned || !params.data || !hasValue) {
|
||||
return valueText
|
||||
}
|
||||
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.style.display = 'flex'
|
||||
wrapper.style.alignItems = 'center'
|
||||
wrapper.style.justifyContent = 'space-between'
|
||||
wrapper.style.gap = '6px'
|
||||
wrapper.style.width = '100%'
|
||||
wrapper.addEventListener('pointerdown', event => event.stopPropagation())
|
||||
wrapper.addEventListener('mousedown', event => event.stopPropagation())
|
||||
wrapper.addEventListener('click', event => event.stopPropagation())
|
||||
wrapper.addEventListener('dblclick', event => event.stopPropagation())
|
||||
|
||||
const checkbox = document.createElement('input')
|
||||
checkbox.type = 'checkbox'
|
||||
checkbox.className = 'cursor-pointer'
|
||||
checkbox.checked = params.data[checkField] !== false
|
||||
checkbox.addEventListener('pointerdown', event => event.stopPropagation())
|
||||
checkbox.addEventListener('mousedown', event => event.stopPropagation())
|
||||
checkbox.addEventListener('click', event => event.stopPropagation())
|
||||
checkbox.addEventListener('change', event => {
|
||||
event.stopPropagation()
|
||||
const targetRow = params.data as TRow | undefined
|
||||
if (!targetRow) return
|
||||
options.onToggle(targetRow, checkbox.checked)
|
||||
void nextTick(() => {
|
||||
params.api?.redrawRows?.({
|
||||
rowNodes: params.node ? [params.node] : undefined
|
||||
})
|
||||
params.api?.refreshCells?.({
|
||||
rowNodes: params.node ? [params.node] : undefined,
|
||||
force: true
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
const valueSpan = document.createElement('span')
|
||||
valueSpan.textContent = valueText
|
||||
valueSpan.addEventListener('pointerdown', event => event.stopPropagation())
|
||||
valueSpan.addEventListener('mousedown', event => event.stopPropagation())
|
||||
valueSpan.addEventListener('click', event => event.stopPropagation())
|
||||
wrapper.append(checkbox, valueSpan)
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
export const createScaleBudgetCellRendererToggleFactory = <TRow extends BudgetCheckRow>(
|
||||
getRows: () => TRow[],
|
||||
onAfterToggle: () => void
|
||||
) => (checkField: ScaleBudgetCheckField) =>
|
||||
createScaleBudgetCellRendererWithCheck<TRow>(checkField, {
|
||||
formatValue: formatScaleReadonlyMoney,
|
||||
onToggle: (targetRow: TRow, checked: boolean) => {
|
||||
updateScaleBudgetCheckState(getRows(), targetRow.id, checkField, checked)
|
||||
onAfterToggle()
|
||||
}
|
||||
})
|
||||
|
||||
export const getScaleMergeColSpanBeforeTotal = (params: any) => {
|
||||
if (!params.node?.group && !params.node?.rowPinned) return 1
|
||||
const displayedColumns = params.api?.getAllDisplayedColumns?.()
|
||||
if (!Array.isArray(displayedColumns) || !params.column) return 1
|
||||
const currentIndex = displayedColumns.findIndex((column: any) => column.getColId() === params.column.getColId())
|
||||
const totalIndex = displayedColumns.findIndex((column: any) => column.getColId() === 'budgetFeeTotal')
|
||||
if (currentIndex < 0 || totalIndex <= currentIndex) return 1
|
||||
return totalIndex - currentIndex
|
||||
}
|
||||
|
||||
export const refreshScaleGridAfterColumnReset = async <TRow>(gridApi: GridApi<TRow> | null | undefined) => {
|
||||
await nextTick()
|
||||
gridApi?.refreshHeader()
|
||||
gridApi?.refreshCells({ force: true })
|
||||
}
|
||||
|
||||
export const restoreScaleColumnDefaults = async <TRow>(options: {
|
||||
gridApi: GridApi<TRow> | null | undefined
|
||||
rows: TRow[]
|
||||
getCurrentValue: (row: TRow) => number | null | undefined
|
||||
getNextValue: (row: TRow) => number | null | undefined
|
||||
isSameValue: (left: number | null | undefined, right: number | null | undefined) => boolean
|
||||
applyValue: (row: TRow, nextValue: number | null) => void
|
||||
afterApply: () => Promise<void>
|
||||
}) => {
|
||||
options.gridApi?.stopEditing()
|
||||
let changed = false
|
||||
for (const row of options.rows) {
|
||||
const nextValue = options.getNextValue(row) ?? null
|
||||
if (options.isSameValue(options.getCurrentValue(row), nextValue)) continue
|
||||
options.applyValue(row, nextValue)
|
||||
changed = true
|
||||
}
|
||||
if (!changed) return false
|
||||
await options.afterApply()
|
||||
await refreshScaleGridAfterColumnReset(options.gridApi)
|
||||
return true
|
||||
}
|
||||
113
src/lib/pricingScaleLink.ts
Normal file
113
src/lib/pricingScaleLink.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { getMajorIdAliasMap } from '@/sql'
|
||||
|
||||
const majorIdAliasMap = getMajorIdAliasMap()
|
||||
|
||||
type ScaleLinkRow = {
|
||||
id?: unknown
|
||||
projectIndex?: unknown
|
||||
majorDictId?: unknown
|
||||
path?: unknown
|
||||
}
|
||||
|
||||
const normalizeProjectCount = (value: unknown) => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
return Math.max(1, Math.floor(parsed))
|
||||
}
|
||||
|
||||
export const parseProjectIndexFromPathKey = (value: string) => {
|
||||
const match = /^project-(\d+)$/.exec(value)
|
||||
if (!match) return null
|
||||
return normalizeProjectCount(Number(match[1]))
|
||||
}
|
||||
|
||||
export const parseScopedRowId = (id: unknown) => {
|
||||
const rawId = String(id || '')
|
||||
const match = /^(\d+)::(.+)$/.exec(rawId)
|
||||
if (!match) {
|
||||
return {
|
||||
projectIndex: 1,
|
||||
majorDictId: rawId
|
||||
}
|
||||
}
|
||||
return {
|
||||
projectIndex: normalizeProjectCount(Number(match[1])),
|
||||
majorDictId: String(match[2] || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveScaleRowProjectIndex = (row: ScaleLinkRow | undefined) => {
|
||||
if (!row) return 1
|
||||
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
|
||||
return normalizeProjectCount(row.projectIndex)
|
||||
}
|
||||
if (Array.isArray(row.path) && row.path.length > 0) {
|
||||
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
|
||||
if (projectIndexFromPath != null) return projectIndexFromPath
|
||||
}
|
||||
return parseScopedRowId(row.id).projectIndex
|
||||
}
|
||||
|
||||
export const resolveScaleRowMajorDictId = (row: ScaleLinkRow | undefined) => {
|
||||
if (!row) return ''
|
||||
const direct = String(row.majorDictId || '').trim()
|
||||
if (direct) return majorIdAliasMap.get(direct) || direct
|
||||
const parsed = parseScopedRowId(row.id).majorDictId
|
||||
return majorIdAliasMap.get(parsed) || parsed
|
||||
}
|
||||
|
||||
export const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
|
||||
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
|
||||
|
||||
export const buildContractScaleMap = <TRow extends ScaleLinkRow>(rows: TRow[] | undefined) => {
|
||||
const map = new Map<string, TRow>()
|
||||
for (const row of rows || []) {
|
||||
const majorDictId = resolveScaleRowMajorDictId(row)
|
||||
if (!majorDictId) continue
|
||||
const projectIndex = resolveScaleRowProjectIndex(row)
|
||||
map.set(makeProjectMajorKey(projectIndex, majorDictId), row)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const buildContractScaleIdMap = <TRow extends ScaleLinkRow>(rows: TRow[] | undefined) => {
|
||||
const map = new Map<string, TRow>()
|
||||
for (const row of rows || []) {
|
||||
const rowId = String(row?.id || '').trim()
|
||||
if (!rowId) continue
|
||||
map.set(rowId, row)
|
||||
const aliasId = majorIdAliasMap.get(rowId)
|
||||
if (aliasId && !map.has(aliasId)) {
|
||||
map.set(aliasId, row)
|
||||
}
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
export const getContractScaleRowByMajor = <TRow extends ScaleLinkRow>(
|
||||
row: ScaleLinkRow,
|
||||
map: Map<string, TRow>,
|
||||
idMap?: Map<string, TRow>
|
||||
) => {
|
||||
const directRowId = String(row.id || '').trim()
|
||||
if (directRowId && idMap?.has(directRowId)) return idMap.get(directRowId)
|
||||
const parsedMajorId = parseScopedRowId(row.id).majorDictId
|
||||
if (parsedMajorId && idMap?.has(parsedMajorId)) return idMap.get(parsedMajorId)
|
||||
const majorDictId = resolveScaleRowMajorDictId(row)
|
||||
if (!majorDictId) return undefined
|
||||
const projectIndex = resolveScaleRowProjectIndex(row)
|
||||
return map.get(makeProjectMajorKey(projectIndex, majorDictId))
|
||||
|| (projectIndex > 1 ? map.get(makeProjectMajorKey(1, majorDictId)) : undefined)
|
||||
}
|
||||
|
||||
export const normalizeChangedScaleRowIds = (rowIds?: Array<string | number>) =>
|
||||
new Set(
|
||||
(rowIds || [])
|
||||
.map(id => {
|
||||
const rawId = String(id || '').trim()
|
||||
if (!rawId) return rawId
|
||||
const parsedMajorId = parseScopedRowId(rawId).majorDictId
|
||||
return majorIdAliasMap.get(parsedMajorId) || parsedMajorId
|
||||
})
|
||||
.filter(Boolean)
|
||||
)
|
||||
147
src/lib/pricingScalePaneData.ts
Normal file
147
src/lib/pricingScalePaneData.ts
Normal file
@ -0,0 +1,147 @@
|
||||
type StoredPricingState<TRow> = {
|
||||
detailRows?: TRow[]
|
||||
projectCount?: unknown
|
||||
} | null | undefined
|
||||
|
||||
type AsyncVoid = () => Promise<void> | void
|
||||
|
||||
type ScalePaneIndustryOptions = {
|
||||
readIndustryCode: () => Promise<string>
|
||||
setIndustryCode: (code: string) => void
|
||||
}
|
||||
|
||||
const refreshIndustryCode = async (options?: ScalePaneIndustryOptions) => {
|
||||
if (!options) return
|
||||
const industryCode = await options.readIndustryCode()
|
||||
options.setIndustryCode(industryCode)
|
||||
}
|
||||
|
||||
export const loadPricingScalePaneRows = async <TRow>(options: {
|
||||
industry?: ScalePaneIndustryOptions
|
||||
setProjectCount: (count: number) => void
|
||||
ensureFactorDefaultsLoaded: AsyncVoid
|
||||
shouldForceDefaultLoad: () => boolean
|
||||
buildContractDefaultRows: (targetProjectCount: number) => Promise<TRow[]>
|
||||
loadStoredState: () => Promise<StoredPricingState<TRow>>
|
||||
isMutipleService: boolean
|
||||
normalizeProjectCount: (value: unknown) => number
|
||||
inferProjectCountFromRows: (rows: TRow[]) => number
|
||||
buildRowsFromStoredState: (rows: TRow[]) => TRow[]
|
||||
buildEmptyRows: (targetProjectCount: number) => TRow[]
|
||||
getTargetProjectCount: () => number
|
||||
applyRows: (rows: TRow[]) => void
|
||||
afterApplyRows: AsyncVoid
|
||||
onError?: (error: unknown) => void
|
||||
}) => {
|
||||
try {
|
||||
await refreshIndustryCode(options.industry)
|
||||
options.setProjectCount(1)
|
||||
|
||||
await options.ensureFactorDefaultsLoaded()
|
||||
|
||||
if (options.shouldForceDefaultLoad()) {
|
||||
options.applyRows(await options.buildContractDefaultRows(options.getTargetProjectCount()))
|
||||
await options.afterApplyRows()
|
||||
return
|
||||
}
|
||||
|
||||
const data = await options.loadStoredState()
|
||||
if (data) {
|
||||
const storedRows = Array.isArray(data.detailRows) ? data.detailRows : []
|
||||
if (options.isMutipleService) {
|
||||
const storedProjectCount = options.normalizeProjectCount(data.projectCount)
|
||||
options.setProjectCount(storedProjectCount || options.inferProjectCountFromRows(storedRows))
|
||||
}
|
||||
options.applyRows(options.buildRowsFromStoredState(storedRows))
|
||||
await options.afterApplyRows()
|
||||
return
|
||||
}
|
||||
|
||||
options.applyRows(await options.buildContractDefaultRows(options.getTargetProjectCount()))
|
||||
await options.afterApplyRows()
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
options.applyRows(options.buildEmptyRows(options.getTargetProjectCount()))
|
||||
await options.afterApplyRows()
|
||||
}
|
||||
}
|
||||
|
||||
export const importPricingScalePaneRows = async <TRow>(options: {
|
||||
industry?: ScalePaneIndustryOptions
|
||||
getTargetProjectCount: () => number
|
||||
buildContractDefaultRows: (targetProjectCount: number) => Promise<TRow[]>
|
||||
applyRows: (rows: TRow[]) => void
|
||||
saveRows: AsyncVoid
|
||||
onError?: (error: unknown) => void
|
||||
}) => {
|
||||
try {
|
||||
await refreshIndustryCode(options.industry)
|
||||
options.applyRows(await options.buildContractDefaultRows(options.getTargetProjectCount()))
|
||||
await options.saveRows()
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPricingScalePaneRows = async <TRow>(options: {
|
||||
getTargetProjectCount: () => number
|
||||
buildEmptyRows: (targetProjectCount: number) => TRow[]
|
||||
applyRows: (rows: TRow[]) => void
|
||||
saveRows: AsyncVoid
|
||||
onError?: (error: unknown) => void
|
||||
}) => {
|
||||
try {
|
||||
options.applyRows(options.buildEmptyRows(options.getTargetProjectCount()))
|
||||
await options.saveRows()
|
||||
} catch (error) {
|
||||
options.onError?.(error)
|
||||
}
|
||||
}
|
||||
|
||||
export const applyPricingScaleProjectCountChange = async <TRow>(options: {
|
||||
nextValue: unknown
|
||||
setProjectCount: (count: number) => void
|
||||
isMutipleService: boolean
|
||||
currentRows: TRow[]
|
||||
cloneRows: (rows: TRow[]) => TRow[]
|
||||
normalizeProjectCount: (value: unknown) => number
|
||||
inferProjectCountFromRows: (rows: TRow[]) => number
|
||||
buildRowsForReducedCount: (rows: TRow[], targetProjectCount: number) => TRow[]
|
||||
buildRowsFromImportDefaultSource: (targetProjectCount: number) => Promise<TRow[]>
|
||||
getRowKey: (row: Partial<TRow> | undefined) => string
|
||||
getRowProjectIndex: (row: Partial<TRow>) => number
|
||||
mergeExistingRow: (defaultRow: TRow, existingRow: TRow) => TRow
|
||||
applyRows: (rows: TRow[]) => void
|
||||
afterApplyRows: AsyncVoid
|
||||
}) => {
|
||||
const normalized = options.normalizeProjectCount(options.nextValue)
|
||||
options.setProjectCount(normalized)
|
||||
if (!options.isMutipleService) return
|
||||
|
||||
const previousRows = options.cloneRows(options.currentRows)
|
||||
const previousProjectCount = options.inferProjectCountFromRows(previousRows)
|
||||
if (normalized === previousProjectCount) return
|
||||
|
||||
if (normalized < previousProjectCount) {
|
||||
options.applyRows(options.buildRowsForReducedCount(previousRows, normalized))
|
||||
await options.afterApplyRows()
|
||||
return
|
||||
}
|
||||
|
||||
const defaultRows = await options.buildRowsFromImportDefaultSource(normalized)
|
||||
const existingMap = new Map<string, TRow>()
|
||||
for (const row of previousRows) {
|
||||
const key = options.getRowKey(row)
|
||||
if (!key) continue
|
||||
existingMap.set(key, row)
|
||||
}
|
||||
|
||||
options.applyRows(defaultRows.map(defaultRow => {
|
||||
const key = options.getRowKey(defaultRow)
|
||||
const existingRow = key ? existingMap.get(key) : undefined
|
||||
if (!existingRow) return defaultRow
|
||||
if (options.getRowProjectIndex(existingRow) > previousProjectCount) return defaultRow
|
||||
return options.mergeExistingRow(defaultRow, existingRow)
|
||||
}))
|
||||
await options.afterApplyRows()
|
||||
}
|
||||
52
src/lib/pricingScalePaneLifecycle.ts
Normal file
52
src/lib/pricingScalePaneLifecycle.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import type { GridApi } from 'ag-grid-community'
|
||||
import { onActivated, onBeforeUnmount, onMounted, watch, type Ref, type WatchSource } from 'vue'
|
||||
|
||||
type AsyncTask = () => Promise<void> | void
|
||||
|
||||
export const usePricingPaneLifecycle = <TRow>(options: {
|
||||
gridApi: Ref<GridApi<TRow> | null>
|
||||
loadFromIndexedDB: () => Promise<void>
|
||||
syncLinkedFields: () => Promise<void>
|
||||
linkedSourceSignature: WatchSource<unknown>
|
||||
linkedSecondarySignature?: WatchSource<unknown>
|
||||
syncSecondaryLinkedFields?: AsyncTask
|
||||
saveToIndexedDB: AsyncTask
|
||||
}) => {
|
||||
const hydratePane = async () => {
|
||||
await options.loadFromIndexedDB()
|
||||
await options.syncLinkedFields()
|
||||
}
|
||||
|
||||
let skipNextActivated = false
|
||||
|
||||
onMounted(async () => {
|
||||
skipNextActivated = true
|
||||
await hydratePane()
|
||||
})
|
||||
|
||||
onActivated(async () => {
|
||||
if (skipNextActivated) {
|
||||
skipNextActivated = false
|
||||
return
|
||||
}
|
||||
await hydratePane()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
options.gridApi.value?.stopEditing()
|
||||
options.gridApi.value = null
|
||||
void options.saveToIndexedDB()
|
||||
})
|
||||
|
||||
watch(options.linkedSourceSignature, () => {
|
||||
void options.syncLinkedFields()
|
||||
})
|
||||
|
||||
if (options.linkedSecondarySignature && options.syncSecondaryLinkedFields) {
|
||||
watch(options.linkedSecondarySignature, () => {
|
||||
void options.syncSecondaryLinkedFields?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export const usePricingScalePaneLifecycle = usePricingPaneLifecycle
|
||||
41
src/lib/pricingScaleProject.ts
Normal file
41
src/lib/pricingScaleProject.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import {
|
||||
makeProjectMajorKey,
|
||||
resolveScaleRowMajorDictId,
|
||||
resolveScaleRowProjectIndex
|
||||
} from '@/lib/pricingScaleLink'
|
||||
|
||||
const PROJECT_PATH_PREFIX = 'project-'
|
||||
const PROJECT_ROW_ID_SEPARATOR = '::'
|
||||
|
||||
export const normalizeScaleProjectCount = (value: unknown) => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
return Math.max(1, Math.floor(parsed))
|
||||
}
|
||||
|
||||
export const buildScaleProjectGroupPathKey = (projectIndex: number) => `${PROJECT_PATH_PREFIX}${projectIndex}`
|
||||
|
||||
export const buildScopedScaleRowId = (
|
||||
isMutipleService: boolean,
|
||||
projectIndex: number,
|
||||
majorId: string
|
||||
) => (isMutipleService ? `${projectIndex}${PROJECT_ROW_ID_SEPARATOR}${majorId}` : majorId)
|
||||
|
||||
export const inferScaleProjectCountFromRows = <TRow>(
|
||||
rows: Array<Partial<TRow>> | undefined,
|
||||
isMutipleService: boolean
|
||||
) => {
|
||||
if (!isMutipleService) return 1
|
||||
let maxProjectIndex = 1
|
||||
for (const row of rows || []) {
|
||||
maxProjectIndex = Math.max(maxProjectIndex, resolveScaleRowProjectIndex(row))
|
||||
}
|
||||
return maxProjectIndex
|
||||
}
|
||||
|
||||
export const getScaleProjectMajorKeyFromRow = <TRow>(row: Partial<TRow> | undefined) => {
|
||||
if (!row) return ''
|
||||
const majorDictId = resolveScaleRowMajorDictId(row)
|
||||
if (!majorDictId) return ''
|
||||
return makeProjectMajorKey(resolveScaleRowProjectIndex(row), majorDictId)
|
||||
}
|
||||
57
src/lib/pricingScaleRowMap.ts
Normal file
57
src/lib/pricingScaleRowMap.ts
Normal file
@ -0,0 +1,57 @@
|
||||
import { makeProjectMajorKey } from '@/lib/pricingScaleLink'
|
||||
|
||||
export const buildScaleProjectMajorMap = <TRow>(
|
||||
rows: TRow[] | undefined,
|
||||
resolveProjectIndex: (row: Partial<TRow>) => number,
|
||||
resolveMajorDictId: (row: Partial<TRow>) => string | undefined
|
||||
) => {
|
||||
const valueMap = new Map<string, TRow>()
|
||||
for (const row of rows || []) {
|
||||
const majorDictId = resolveMajorDictId(row)
|
||||
if (!majorDictId) continue
|
||||
valueMap.set(makeProjectMajorKey(resolveProjectIndex(row), majorDictId), row)
|
||||
}
|
||||
return valueMap
|
||||
}
|
||||
|
||||
export const getScaleProjectMajorMappedRow = <TRow>(
|
||||
valueMap: Map<string, TRow>,
|
||||
projectIndex: number,
|
||||
majorDictId: string,
|
||||
options?: { cloneFromProjectOne?: boolean }
|
||||
) => (
|
||||
valueMap.get(makeProjectMajorKey(projectIndex, majorDictId))
|
||||
|| (
|
||||
options?.cloneFromProjectOne && projectIndex > 1
|
||||
? valueMap.get(makeProjectMajorKey(1, majorDictId))
|
||||
: undefined
|
||||
)
|
||||
)
|
||||
|
||||
export const mergeScaleRowsFromProjectMajorMap = <TRow, TSource>(options: {
|
||||
rowsFromDb: TSource[] | undefined
|
||||
projectCount: number
|
||||
buildDefaultRows: (projectCount: number) => TRow[]
|
||||
resolveProjectIndex: (row: Partial<TRow> | Partial<TSource>) => number
|
||||
resolveMajorDictId: (row: Partial<TRow> | Partial<TSource>) => string | undefined
|
||||
cloneFromProjectOne?: boolean
|
||||
mergeRow: (defaultRow: TRow, fromDb: TSource | undefined) => TRow
|
||||
}) => {
|
||||
const valueMap = buildScaleProjectMajorMap(
|
||||
options.rowsFromDb,
|
||||
row => options.resolveProjectIndex(row),
|
||||
row => options.resolveMajorDictId(row)
|
||||
)
|
||||
|
||||
return options.buildDefaultRows(options.projectCount).map(defaultRow => {
|
||||
const majorDictId = options.resolveMajorDictId(defaultRow)
|
||||
if (!majorDictId) return defaultRow
|
||||
const fromDb = getScaleProjectMajorMappedRow(
|
||||
valueMap,
|
||||
options.resolveProjectIndex(defaultRow),
|
||||
majorDictId,
|
||||
{ cloneFromProjectOne: options.cloneFromProjectOne }
|
||||
)
|
||||
return options.mergeRow(defaultRow, fromDb)
|
||||
})
|
||||
}
|
||||
@ -69,12 +69,15 @@ export const mergeWorkloadRows = (
|
||||
return buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap).map(row => {
|
||||
const fromDb = dbMap.get(row.id)
|
||||
if (!fromDb) return row
|
||||
const hasConsultCategoryFactor = Object.prototype.hasOwnProperty.call(fromDb, 'consultCategoryFactor')
|
||||
return {
|
||||
...row,
|
||||
workload: toFiniteNumberOrNull(fromDb.workload),
|
||||
basicFee: toFiniteNumberOrNull(fromDb.basicFee),
|
||||
budgetAdoptedUnitPrice: toFiniteNumberOrNull(fromDb.budgetAdoptedUnitPrice),
|
||||
consultCategoryFactor: toFiniteNumberOrNull(fromDb.consultCategoryFactor)
|
||||
consultCategoryFactor:
|
||||
toFiniteNumberOrNull(fromDb.consultCategoryFactor)
|
||||
?? (hasConsultCategoryFactor ? null : row.consultCategoryFactor)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
651
src/lib/reportExportBuilders.ts
Normal file
651
src/lib/reportExportBuilders.ts
Normal file
@ -0,0 +1,651 @@
|
||||
import { serviceList } from '@/sql'
|
||||
import { roundTo } from '@/lib/decimal'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
|
||||
interface ScaleMethodRowLike {
|
||||
id: string
|
||||
majorDictId?: unknown
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
benchmarkBudgetBasicChecked?: unknown
|
||||
benchmarkBudgetOptionalChecked?: unknown
|
||||
basicFormula?: unknown
|
||||
optionalFormula?: unknown
|
||||
budgetFee?: unknown
|
||||
budgetFeeBasic?: unknown
|
||||
budgetFeeOptional?: unknown
|
||||
consultCategoryFactor?: unknown
|
||||
majorFactor?: unknown
|
||||
workStageFactor?: unknown
|
||||
workRatio?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
interface WorkContentRowLike {
|
||||
id?: unknown
|
||||
content?: unknown
|
||||
checked?: unknown
|
||||
custom?: unknown
|
||||
serviceid?: unknown
|
||||
isAddTrigger?: unknown
|
||||
}
|
||||
|
||||
interface FactorRowLike {
|
||||
id: string
|
||||
budgetValue?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
interface ScaleRowLike {
|
||||
id: string
|
||||
amount: number | null
|
||||
landArea: number | null
|
||||
}
|
||||
|
||||
interface WorkloadMethodRowLike {
|
||||
id: string
|
||||
budgetAdoptedUnitPrice?: unknown
|
||||
workload?: unknown
|
||||
basicFee?: unknown
|
||||
consultCategoryFactor?: unknown
|
||||
serviceFee?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
interface HourlyMethodRowLike {
|
||||
id: string
|
||||
adoptedBudgetUnitPrice?: unknown
|
||||
personnelCount?: unknown
|
||||
workdayCount?: unknown
|
||||
serviceBudget?: unknown
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
interface ZxFwRowLike {
|
||||
id: string
|
||||
process?: unknown
|
||||
subtotal?: unknown
|
||||
finalFee?: unknown
|
||||
investScale?: unknown
|
||||
landScale?: unknown
|
||||
workload?: unknown
|
||||
hourly?: unknown
|
||||
}
|
||||
|
||||
interface RateMethodRowLike {
|
||||
rate?: unknown
|
||||
budgetFee?: unknown
|
||||
}
|
||||
|
||||
interface QuantityMethodRowLike {
|
||||
id?: unknown
|
||||
feeItem?: unknown
|
||||
unit?: unknown
|
||||
quantity?: unknown
|
||||
unitPrice?: unknown
|
||||
budgetFee?: number | null
|
||||
remark?: unknown
|
||||
}
|
||||
|
||||
interface ExportScaleRowLike {
|
||||
majorid: number
|
||||
major: number
|
||||
cost: number | null
|
||||
area: number | null
|
||||
}
|
||||
|
||||
interface ExportServiceCoeLike {
|
||||
serviceid: number
|
||||
coe: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface ExportMajorCoeLike {
|
||||
majorid: number
|
||||
coe: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface ExportMethod1DetailLike {
|
||||
proNum: number
|
||||
major: number
|
||||
cost: number
|
||||
basicFee: number
|
||||
basicFormula: string
|
||||
basicFee_basic: number
|
||||
optionalFormula: string
|
||||
basicFee_optional: number
|
||||
serviceCoe: number
|
||||
majorCoe: number
|
||||
processCoe: number
|
||||
proportion: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface ExportMethod2DetailLike {
|
||||
proNum: number
|
||||
major: number
|
||||
area: number
|
||||
basicFee: number
|
||||
basicFormula: string
|
||||
basicFee_basic: number
|
||||
optionalFormula: string
|
||||
basicFee_optional: number
|
||||
serviceCoe: number
|
||||
majorCoe: number
|
||||
processCoe: number
|
||||
proportion: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface ExportMethod3DetailLike {
|
||||
task: number
|
||||
price: number
|
||||
amount: number
|
||||
basicFee: number
|
||||
serviceCoe: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface ExportMethod4DetailLike {
|
||||
expert: number
|
||||
price: number
|
||||
person_num: number
|
||||
work_day: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface ExportMethod5DetailLike {
|
||||
name: string
|
||||
unit: string
|
||||
amount: number
|
||||
price: number
|
||||
fee: number
|
||||
remark: string
|
||||
}
|
||||
|
||||
export const toFiniteNumber = (value: unknown): number | null => {
|
||||
if (typeof value === 'number') {
|
||||
return Number.isFinite(value) ? value : null
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed) return null
|
||||
const num = Number(trimmed)
|
||||
return Number.isFinite(num) ? num : null
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export const toFiniteNumberOrZero = (value: unknown): number => toFiniteNumber(value) ?? 0
|
||||
|
||||
export const toSafeInteger = (value: unknown): number | null => {
|
||||
const num = Number(value)
|
||||
if (!Number.isInteger(num)) return null
|
||||
if (!Number.isSafeInteger(num)) return null
|
||||
return num
|
||||
}
|
||||
|
||||
export const sumNumbers = (values: Array<number | null | undefined>): number =>
|
||||
values.reduce<number>(
|
||||
(sum, value) => sum + (typeof value === 'number' && Number.isFinite(value) ? value : 0),
|
||||
0
|
||||
)
|
||||
|
||||
export const toMoney = (value: unknown): number => roundTo(toFiniteNumber(value) ?? 0, 2)
|
||||
|
||||
export const isNonEmptyString = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.trim().length > 0
|
||||
|
||||
export const getTaskIdFromRowId = (value: string): number | null => {
|
||||
const match = /^task-(\d+)-\d+$/.exec(value)
|
||||
return match ? toSafeInteger(match[1]) : null
|
||||
}
|
||||
|
||||
export const getExpertIdFromRowId = (value: string): number | null => {
|
||||
const match = /^expert-(\d+)$/.exec(value)
|
||||
return match ? toSafeInteger(match[1]) : null
|
||||
}
|
||||
|
||||
export const hasServiceId = (serviceId: string) =>
|
||||
Object.prototype.hasOwnProperty.call(serviceList as Record<string, unknown>, serviceId)
|
||||
|
||||
export const sortServiceIdsByDict = (ids: string[]) =>
|
||||
[...ids].sort((left, right) => {
|
||||
const leftOrder = Number((serviceList as Record<string, any>)[left]?.order)
|
||||
const rightOrder = Number((serviceList as Record<string, any>)[right]?.order)
|
||||
const safeLeft = Number.isFinite(leftOrder) ? leftOrder : Number.MAX_SAFE_INTEGER
|
||||
const safeRight = Number.isFinite(rightOrder) ? rightOrder : Number.MAX_SAFE_INTEGER
|
||||
return safeLeft - safeRight
|
||||
})
|
||||
|
||||
export const mapIndustryCodeToExportIndustry = (value: unknown): number => {
|
||||
const raw = typeof value === 'string' ? value.trim().toUpperCase() : ''
|
||||
if (!raw) return 0
|
||||
if (raw === '0' || raw === 'E2') return 0
|
||||
if (raw === '1' || raw === 'E3') return 1
|
||||
if (raw === '2' || raw === 'E4') return 2
|
||||
return 0
|
||||
}
|
||||
|
||||
export const parseScaleScopedRowId = (rowId: unknown) => {
|
||||
const raw = String(rowId || '').trim()
|
||||
const scopedMatch = /^(\d+)::(.+)$/.exec(raw)
|
||||
if (scopedMatch) {
|
||||
return {
|
||||
proNum: toSafeInteger(scopedMatch[1]) ?? 1,
|
||||
majorPart: String(scopedMatch[2] || '').trim()
|
||||
}
|
||||
}
|
||||
return {
|
||||
proNum: 1,
|
||||
majorPart: raw
|
||||
}
|
||||
}
|
||||
|
||||
export const toScaleMajorId = (row: ScaleMethodRowLike): number | null => {
|
||||
const direct = toSafeInteger((row as { majorDictId?: unknown }).majorDictId)
|
||||
if (direct != null) return direct
|
||||
const parsed = parseScaleScopedRowId(row.id)
|
||||
return toSafeInteger(parsed.majorPart)
|
||||
}
|
||||
|
||||
export const toScaleProNum = (row: ScaleMethodRowLike): number => {
|
||||
const parsed = parseScaleScopedRowId(row.id)
|
||||
return parsed.proNum > 0 ? parsed.proNum : 1
|
||||
}
|
||||
|
||||
export const normalizeTaskText = (value: unknown): string => String(value || '').trim()
|
||||
|
||||
export const resolveTaskRowServiceId = (row: WorkContentRowLike): number | null =>
|
||||
toSafeInteger((row as { serviceid?: unknown })?.serviceid)
|
||||
|
||||
export const resolveScaleMethodFee = (row: ScaleMethodRowLike, mode: 'cost' | 'area') => {
|
||||
const scaleValue = mode === 'cost' ? toFiniteNumber(row.amount) : toFiniteNumber(row.landArea)
|
||||
const benchmarkSplit = getBenchmarkBudgetSplitByScale(scaleValue, mode)
|
||||
const basicChecked = row.benchmarkBudgetBasicChecked !== false
|
||||
const optionalChecked = row.benchmarkBudgetOptionalChecked !== false
|
||||
const allUnchecked = !basicChecked && !optionalChecked
|
||||
const benchmarkBudgetBasic = benchmarkSplit ? (basicChecked ? benchmarkSplit.basic : 0) : null
|
||||
const benchmarkBudgetOptional = benchmarkSplit ? (optionalChecked ? benchmarkSplit.optional : 0) : null
|
||||
const computedSplit = benchmarkSplit
|
||||
? getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic,
|
||||
benchmarkBudgetOptional,
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})
|
||||
: null
|
||||
const basicFee = allUnchecked ? null : (toFiniteNumber(row.budgetFee) ?? computedSplit?.total ?? null)
|
||||
const basicFeeBasic = allUnchecked ? null : (toFiniteNumber(row.budgetFeeBasic) ?? computedSplit?.basic ?? null)
|
||||
const basicFeeOptional = allUnchecked ? null : (toFiniteNumber(row.budgetFeeOptional) ?? computedSplit?.optional ?? null)
|
||||
const basicFormula = typeof row.basicFormula === 'string' && row.basicFormula.trim()
|
||||
? row.basicFormula
|
||||
: (basicChecked ? (benchmarkSplit?.basicFormula ?? '') : '')
|
||||
const optionalFormula = typeof row.optionalFormula === 'string' && row.optionalFormula.trim()
|
||||
? row.optionalFormula
|
||||
: (optionalChecked ? (benchmarkSplit?.optionalFormula ?? '') : '')
|
||||
return {
|
||||
basicFee,
|
||||
basicFeeBasic,
|
||||
basicFeeOptional,
|
||||
basicFormula,
|
||||
optionalFormula
|
||||
}
|
||||
}
|
||||
|
||||
export interface ExportTaskGroupLike {
|
||||
serviceid?: number
|
||||
text: string[]
|
||||
}
|
||||
|
||||
export const groupWorkContentTasks = (
|
||||
rows: WorkContentRowLike[] | undefined,
|
||||
options?: { forceUngroup?: boolean }
|
||||
): ExportTaskGroupLike[] => {
|
||||
const source = Array.isArray(rows) ? rows : []
|
||||
const selected = source.filter(item => {
|
||||
if (item && item.isAddTrigger === true) return false
|
||||
const isCustom = Boolean(item?.custom)
|
||||
const isChecked = Boolean(item?.checked)
|
||||
return isCustom || isChecked
|
||||
})
|
||||
if (selected.length === 0) return []
|
||||
|
||||
const hasGroup = !options?.forceUngroup && selected.some(item => resolveTaskRowServiceId(item) != null)
|
||||
if (!hasGroup) {
|
||||
const text = selected
|
||||
.map(item => normalizeTaskText(item?.content))
|
||||
.filter(Boolean)
|
||||
return text.length > 0 ? [{ text }] : []
|
||||
}
|
||||
|
||||
const grouped = new Map<number, string[]>()
|
||||
const orderedServiceIds: number[] = []
|
||||
const ungroupedText: string[] = []
|
||||
for (const item of selected) {
|
||||
const content = normalizeTaskText(item?.content)
|
||||
if (!content) continue
|
||||
const serviceid = resolveTaskRowServiceId(item)
|
||||
if (serviceid == null) {
|
||||
ungroupedText.push(content)
|
||||
continue
|
||||
}
|
||||
if (!grouped.has(serviceid)) {
|
||||
grouped.set(serviceid, [])
|
||||
orderedServiceIds.push(serviceid)
|
||||
}
|
||||
grouped.get(serviceid)?.push(content)
|
||||
}
|
||||
|
||||
const groupedTasks: ExportTaskGroupLike[] = []
|
||||
for (const serviceid of orderedServiceIds) {
|
||||
const text = grouped.get(serviceid) || []
|
||||
if (text.length === 0) continue
|
||||
groupedTasks.push({ serviceid, text })
|
||||
}
|
||||
|
||||
if (ungroupedText.length > 0) {
|
||||
groupedTasks.push({ text: ungroupedText })
|
||||
}
|
||||
return groupedTasks
|
||||
}
|
||||
|
||||
export const buildProjectServiceCoes = (rows: FactorRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows
|
||||
.map(row => {
|
||||
const serviceid = toSafeInteger(row.id)
|
||||
if (serviceid == null || row.budgetValue == null) return null
|
||||
const coe = toFiniteNumber(row.budgetValue)
|
||||
return {
|
||||
serviceid,
|
||||
coe: coe ?? 0,
|
||||
remark: typeof row.remark === 'string' ? row.remark : ''
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportServiceCoeLike => Boolean(item))
|
||||
}
|
||||
|
||||
export const buildProjectMajorCoes = (rows: FactorRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows
|
||||
.map(row => {
|
||||
const majorid = toSafeInteger(row.id)
|
||||
if (majorid == null || row.budgetValue == null) return null
|
||||
const coe = toFiniteNumber(row.budgetValue)
|
||||
return {
|
||||
majorid,
|
||||
coe: coe ?? 0,
|
||||
remark: typeof row.remark === 'string' ? row.remark : ''
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMajorCoeLike => Boolean(item))
|
||||
}
|
||||
|
||||
export const toExportScaleRows = (rows: ScaleRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return []
|
||||
return rows
|
||||
.map(row => {
|
||||
const majorid = toSafeInteger(row.id)
|
||||
if (majorid == null || (row.amount == null && row.landArea == null)) return null
|
||||
return {
|
||||
majorid,
|
||||
major: majorid,
|
||||
cost: row.amount,
|
||||
area: row.landArea
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportScaleRowLike => Boolean(item))
|
||||
}
|
||||
|
||||
export const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const proSet = new Set<number>()
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const major = toScaleMajorId(row)
|
||||
if (major == null) return null
|
||||
const proNum = toScaleProNum(row)
|
||||
proSet.add(proNum)
|
||||
const cost = toFiniteNumber(row.amount)
|
||||
const feeResolved = resolveScaleMethodFee(row, 'cost')
|
||||
const basicFee = feeResolved.basicFee
|
||||
if (basicFee != null) hasTotalValue = true
|
||||
const basicFeeBasic = feeResolved.basicFeeBasic
|
||||
const basicFeeOptional = feeResolved.basicFeeOptional
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
if (basicFee == null) return null
|
||||
return {
|
||||
proNum,
|
||||
major,
|
||||
cost: cost ?? 0,
|
||||
basicFee: toMoney(basicFee),
|
||||
basicFormula: feeResolved.basicFormula,
|
||||
basicFee_basic: toMoney(basicFeeBasic),
|
||||
optionalFormula: feeResolved.optionalFormula,
|
||||
basicFee_optional: toMoney(basicFeeOptional),
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||||
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
|
||||
proportion: toFiniteNumber(row.workRatio) ?? 1,
|
||||
fee: toMoney(basicFee),
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod1DetailLike => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
proAmount: proSet.size > 0 ? proSet.size : 1,
|
||||
cost: sumNumbers(det.map(item => item.cost)),
|
||||
basicFee: toMoney(sumNumbers(det.map(item => item.basicFee))),
|
||||
basicFee_basic: toMoney(sumNumbers(det.map(item => item.basicFee_basic))),
|
||||
basicFee_optional: toMoney(sumNumbers(det.map(item => item.basicFee_optional))),
|
||||
fee: toMoney(sumNumbers(det.map(item => item.fee))),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const proSet = new Set<number>()
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const major = toScaleMajorId(row)
|
||||
if (major == null) return null
|
||||
const proNum = toScaleProNum(row)
|
||||
proSet.add(proNum)
|
||||
const area = toFiniteNumber(row.landArea)
|
||||
const feeResolved = resolveScaleMethodFee(row, 'area')
|
||||
const basicFee = feeResolved.basicFee
|
||||
if (basicFee != null) hasTotalValue = true
|
||||
const basicFeeBasic = feeResolved.basicFeeBasic
|
||||
const basicFeeOptional = feeResolved.basicFeeOptional
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
if (basicFee == null) return null
|
||||
return {
|
||||
proNum,
|
||||
major,
|
||||
area: area ?? 0,
|
||||
basicFee: toMoney(basicFee),
|
||||
basicFormula: feeResolved.basicFormula,
|
||||
basicFee_basic: toMoney(basicFeeBasic),
|
||||
optionalFormula: feeResolved.optionalFormula,
|
||||
basicFee_optional: toMoney(basicFeeOptional),
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
majorCoe: toFiniteNumberOrZero(row.majorFactor),
|
||||
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
|
||||
proportion: toFiniteNumber(row.workRatio) ?? 1,
|
||||
fee: toMoney(basicFee),
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod2DetailLike => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
proAmount: proSet.size > 0 ? proSet.size : 1,
|
||||
area: sumNumbers(det.map(item => item.area)),
|
||||
basicFee: toMoney(sumNumbers(det.map(item => item.basicFee))),
|
||||
basicFee_basic: toMoney(sumNumbers(det.map(item => item.basicFee_basic))),
|
||||
basicFee_optional: toMoney(sumNumbers(det.map(item => item.basicFee_optional))),
|
||||
fee: toMoney(sumNumbers(det.map(item => item.fee))),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const task = getTaskIdFromRowId(row.id)
|
||||
if (task == null || row.basicFee == null) return null
|
||||
const amount = toFiniteNumber(row.workload)
|
||||
const basicFee = toFiniteNumber(row.basicFee)
|
||||
const fee = toFiniteNumber(row.serviceFee)
|
||||
if (fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue = amount != null || basicFee != null || fee != null || isNonEmptyString(remark)
|
||||
if (!hasValue) return null
|
||||
return {
|
||||
task,
|
||||
price: toFiniteNumberOrZero(row.budgetAdoptedUnitPrice),
|
||||
amount: amount ?? 0,
|
||||
basicFee: basicFee ?? 0,
|
||||
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
|
||||
fee: fee ?? 0,
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod3DetailLike => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
basicFee: sumNumbers(det.map(item => item.basicFee)),
|
||||
fee: sumNumbers(det.map(item => item.fee)),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows)) return null
|
||||
let hasTotalValue = false
|
||||
const det = rows
|
||||
.map(row => {
|
||||
const expert = getExpertIdFromRowId(row.id)
|
||||
if (expert == null || row.serviceBudget == null) return null
|
||||
const personNum = toFiniteNumber(row.personnelCount)
|
||||
const workDay = toFiniteNumber(row.workdayCount)
|
||||
const fee = toFiniteNumber(row.serviceBudget)
|
||||
if (fee != null) hasTotalValue = true
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
const hasValue = personNum != null || workDay != null || fee != null || isNonEmptyString(remark)
|
||||
if (!hasValue) return null
|
||||
return {
|
||||
expert,
|
||||
price: toFiniteNumberOrZero(row.adoptedBudgetUnitPrice),
|
||||
person_num: personNum ?? 0,
|
||||
work_day: workDay ?? 0,
|
||||
fee: fee ?? 0,
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod4DetailLike => Boolean(item))
|
||||
|
||||
if (det.length === 0 || !hasTotalValue) return null
|
||||
return {
|
||||
person_num: sumNumbers(det.map(item => item.person_num)),
|
||||
work_day: sumNumbers(det.map(item => item.work_day)),
|
||||
fee: sumNumbers(det.map(item => item.fee)),
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
export const buildServiceFee = (
|
||||
row: ZxFwRowLike | null | undefined,
|
||||
method1: { fee: number } | null,
|
||||
method2: { fee: number } | null,
|
||||
method3: { fee: number } | null,
|
||||
method4: { fee: number } | null
|
||||
) => {
|
||||
const subtotal = toFiniteNumber(row?.subtotal)
|
||||
if (subtotal != null) return subtotal
|
||||
|
||||
const methodSum = sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
|
||||
if (methodSum !== 0) return methodSum
|
||||
|
||||
return sumNumbers([
|
||||
toFiniteNumber(row?.investScale),
|
||||
toFiniteNumber(row?.landScale),
|
||||
toFiniteNumber(row?.workload),
|
||||
toFiniteNumber(row?.hourly)
|
||||
])
|
||||
}
|
||||
|
||||
export const buildMethod0 = (payload: RateMethodRowLike | null | undefined) => {
|
||||
const coe = toFiniteNumber(payload?.rate)
|
||||
const fee = toFiniteNumber(payload?.budgetFee)
|
||||
if (fee == null) return null
|
||||
return {
|
||||
coe: coe ?? 0,
|
||||
fee
|
||||
}
|
||||
}
|
||||
|
||||
export const buildMethod5 = (rows: QuantityMethodRowLike[] | undefined) => {
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null
|
||||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||||
const subtotalFee = toFiniteNumber(subtotalRow?.budgetFee)
|
||||
if (subtotalFee == null) return null
|
||||
const det = rows
|
||||
.filter(row => String(row?.id || '') !== 'fee-subtotal-fixed')
|
||||
.map(row => {
|
||||
const quantity = toFiniteNumber(row.quantity)
|
||||
const unitPrice = toFiniteNumber(row.unitPrice)
|
||||
const fee = toFiniteNumber(row.budgetFee)
|
||||
const name = typeof row.feeItem === 'string' ? row.feeItem : ''
|
||||
const unit = typeof row.unit === 'string' ? row.unit : ''
|
||||
const remark = typeof row.remark === 'string' ? row.remark : ''
|
||||
|
||||
if (row.budgetFee == null) return null
|
||||
return {
|
||||
name,
|
||||
unit,
|
||||
amount: quantity ?? 0,
|
||||
price: unitPrice ?? 0,
|
||||
fee: fee ?? 0,
|
||||
remark
|
||||
}
|
||||
})
|
||||
.filter((item): item is ExportMethod5DetailLike => Boolean(item))
|
||||
|
||||
if (det.length === 0) return null
|
||||
return {
|
||||
fee: subtotalFee,
|
||||
det
|
||||
}
|
||||
}
|
||||
|
||||
export const buildServiceFinalFee = (
|
||||
row: ZxFwRowLike | null | undefined,
|
||||
method1: { fee: number } | null,
|
||||
method2: { fee: number } | null,
|
||||
method3: { fee: number } | null,
|
||||
method4: { fee: number } | null
|
||||
) => {
|
||||
const finalFee = toFiniteNumber(row?.finalFee)
|
||||
if (finalFee != null) return finalFee
|
||||
const subtotal = toFiniteNumber(row?.subtotal)
|
||||
if (subtotal != null) return subtotal
|
||||
return sumNumbers([method1?.fee, method2?.fee, method3?.fee, method4?.fee])
|
||||
}
|
||||
@ -35,10 +35,12 @@ const resolveFactorValue = (
|
||||
fallbackStandard: number | null
|
||||
): number | null => {
|
||||
if (!row) return null
|
||||
const budgetValue = toFiniteNumberOrNull(row.budgetValue)
|
||||
if (budgetValue != null) return budgetValue
|
||||
const standardFactor = toFiniteNumberOrNull(row.standardFactor)
|
||||
if (standardFactor != null) return standardFactor
|
||||
if (Object.prototype.hasOwnProperty.call(row, 'budgetValue')) {
|
||||
return toFiniteNumberOrNull(row.budgetValue)
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(row, 'standardFactor')) {
|
||||
return toFiniteNumberOrNull(row.standardFactor)
|
||||
}
|
||||
return fallbackStandard
|
||||
}
|
||||
|
||||
|
||||
@ -1,16 +1,26 @@
|
||||
import { roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
import {
|
||||
isSameNullableNumber,
|
||||
isSameScaleDetailRow,
|
||||
recomputeScaleDetailRow
|
||||
} from '@/lib/pricingScaleDetail'
|
||||
import {
|
||||
buildContractScaleIdMap,
|
||||
buildContractScaleMap,
|
||||
getContractScaleRowByMajor,
|
||||
normalizeChangedScaleRowIds,
|
||||
parseScopedRowId,
|
||||
resolveScaleRowMajorDictId as resolveRowMajorDictId
|
||||
} from '@/lib/pricingScaleLink'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { getMajorDictEntries, getServiceDictItemById } from '@/sql'
|
||||
import { getServiceDictItemById } from '@/sql'
|
||||
import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing'
|
||||
|
||||
export type { ZxFwPricingField } from '@/pinia/zxFwPricing'
|
||||
|
||||
const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const
|
||||
const PROJECT_ROW_ID_SEPARATOR = '::'
|
||||
const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__'
|
||||
const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id]))
|
||||
|
||||
type ServiceLite = {
|
||||
mutiple?: boolean | null
|
||||
@ -40,134 +50,32 @@ type ScaleDetailRow = {
|
||||
path?: string[]
|
||||
}
|
||||
|
||||
const normalizeProjectCount = (value: unknown) => {
|
||||
const parsed = Number(value)
|
||||
if (!Number.isFinite(parsed)) return 1
|
||||
return Math.max(1, Math.floor(parsed))
|
||||
}
|
||||
|
||||
const parseProjectIndexFromPathKey = (value: string) => {
|
||||
const match = /^project-(\d+)$/.exec(value)
|
||||
if (!match) return null
|
||||
return normalizeProjectCount(Number(match[1]))
|
||||
}
|
||||
|
||||
const parseScopedRowId = (id: unknown) => {
|
||||
const rawId = String(id || '')
|
||||
const match = /^(\d+)::(.+)$/.exec(rawId)
|
||||
if (!match) {
|
||||
return {
|
||||
projectIndex: 1,
|
||||
majorDictId: rawId
|
||||
}
|
||||
}
|
||||
return {
|
||||
projectIndex: normalizeProjectCount(Number(match[1])),
|
||||
majorDictId: String(match[2] || '').trim()
|
||||
}
|
||||
}
|
||||
|
||||
const resolveRowProjectIndex = (row: Partial<ScaleDetailRow> | undefined) => {
|
||||
if (!row) return 1
|
||||
if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) {
|
||||
return normalizeProjectCount(row.projectIndex)
|
||||
}
|
||||
if (Array.isArray(row.path) && row.path.length > 0) {
|
||||
const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || ''))
|
||||
if (projectIndexFromPath != null) return projectIndexFromPath
|
||||
}
|
||||
return parseScopedRowId(row.id).projectIndex
|
||||
}
|
||||
|
||||
const resolveRowMajorDictId = (row: Partial<ScaleDetailRow> | undefined) => {
|
||||
if (!row) return ''
|
||||
const direct = String(row.majorDictId || '').trim()
|
||||
if (direct) return majorIdAliasMap.get(direct) || direct
|
||||
const parsed = parseScopedRowId(row.id).majorDictId
|
||||
return majorIdAliasMap.get(parsed) || parsed
|
||||
}
|
||||
|
||||
const makeProjectMajorKey = (projectIndex: number, majorDictId: string) =>
|
||||
`${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}`
|
||||
|
||||
const buildContractScaleMap = (rows: ScaleDetailRow[] | undefined) => {
|
||||
const map = new Map<string, ScaleDetailRow>()
|
||||
for (const row of rows || []) {
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
if (!majorDictId) continue
|
||||
const projectIndex = resolveRowProjectIndex(row)
|
||||
map.set(makeProjectMajorKey(projectIndex, majorDictId), row)
|
||||
}
|
||||
return map
|
||||
}
|
||||
|
||||
const getContractScaleRowByMajor = (row: ScaleDetailRow, map: Map<string, ScaleDetailRow>) => {
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
if (!majorDictId) return undefined
|
||||
const projectIndex = resolveRowProjectIndex(row)
|
||||
return map.get(makeProjectMajorKey(projectIndex, majorDictId))
|
||||
|| (projectIndex > 1 ? map.get(makeProjectMajorKey(1, majorDictId)) : undefined)
|
||||
}
|
||||
|
||||
const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) =>
|
||||
sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null))
|
||||
|
||||
const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => {
|
||||
if (left == null && right == null) return true
|
||||
if (left == null || right == null) return false
|
||||
return roundTo(left, 6) === roundTo(right, 6)
|
||||
}
|
||||
|
||||
const recomputeScaleRow = (
|
||||
row: ScaleDetailRow,
|
||||
mode: 'cost' | 'area'
|
||||
): ScaleDetailRow => {
|
||||
const scaleValue = mode === 'cost' ? row.amount : row.landArea
|
||||
const rawSplit = getBenchmarkBudgetSplitByScale(scaleValue, mode)
|
||||
const checkedSplit = rawSplit
|
||||
? {
|
||||
basic: row.benchmarkBudgetBasicChecked === false ? 0 : rawSplit.basic,
|
||||
optional: row.benchmarkBudgetOptionalChecked === false ? 0 : rawSplit.optional,
|
||||
total:
|
||||
(row.benchmarkBudgetBasicChecked === false ? 0 : rawSplit.basic)
|
||||
+ (row.benchmarkBudgetOptionalChecked === false ? 0 : rawSplit.optional)
|
||||
}
|
||||
: null
|
||||
const budgetFeeSplit = checkedSplit
|
||||
? getScaleBudgetFeeSplit({
|
||||
benchmarkBudgetBasic: checkedSplit.basic,
|
||||
benchmarkBudgetOptional: checkedSplit.optional,
|
||||
majorFactor: row.majorFactor,
|
||||
consultCategoryFactor: row.consultCategoryFactor,
|
||||
workStageFactor: row.workStageFactor,
|
||||
workRatio: row.workRatio
|
||||
})
|
||||
: null
|
||||
|
||||
return {
|
||||
...row,
|
||||
benchmarkBudget: checkedSplit ? roundTo(checkedSplit.total, 2) : null,
|
||||
benchmarkBudgetBasic: checkedSplit ? roundTo(checkedSplit.basic, 2) : null,
|
||||
benchmarkBudgetOptional: checkedSplit ? roundTo(checkedSplit.optional, 2) : null,
|
||||
basicFormula: row.benchmarkBudgetBasicChecked === false ? null : (rawSplit?.basicFormula ?? ''),
|
||||
optionalFormula: row.benchmarkBudgetOptionalChecked === false ? null : (rawSplit?.optionalFormula ?? ''),
|
||||
budgetFee: budgetFeeSplit?.total ?? null,
|
||||
budgetFeeBasic: budgetFeeSplit?.basic ?? null,
|
||||
budgetFeeOptional: budgetFeeSplit?.optional ?? null
|
||||
}
|
||||
}
|
||||
|
||||
const getScaleMethodTotalBudgetFee = (rows: ScaleDetailRow[]) => {
|
||||
const hasValue = rows.some(row => typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee))
|
||||
if (!hasValue) return null
|
||||
return roundTo(sumByNumber(rows, row => row.budgetFee ?? null), 2)
|
||||
}
|
||||
|
||||
const matchesChangedScaleRow = (row: ScaleDetailRow, changedRowIds?: Set<string>) => {
|
||||
if (!changedRowIds || changedRowIds.size === 0) return true
|
||||
const directRowId = String(row.id || '').trim()
|
||||
if (directRowId && changedRowIds.has(directRowId)) return true
|
||||
const parsedMajorId = parseScopedRowId(row.id).majorDictId
|
||||
if (parsedMajorId && changedRowIds.has(parsedMajorId)) return true
|
||||
const majorDictId = resolveRowMajorDictId(row)
|
||||
return Boolean(majorDictId && changedRowIds.has(majorDictId))
|
||||
}
|
||||
|
||||
const syncScaleMethodRows = async (params: {
|
||||
contractId: string
|
||||
serviceId: string
|
||||
method: ServicePricingMethod
|
||||
sourceRowMap: Map<string, ScaleDetailRow>
|
||||
sourceRowIdMap: Map<string, ScaleDetailRow>
|
||||
changedRowIds?: Set<string>
|
||||
onlyCostScaleFallbackAmount?: number | null
|
||||
isOnlyCostScaleService?: boolean
|
||||
}) => {
|
||||
@ -177,13 +85,17 @@ const syncScaleMethodRows = async (params: {
|
||||
params.serviceId,
|
||||
params.method
|
||||
)
|
||||
if (!methodState?.detailRows?.length) return
|
||||
if (!methodState?.detailRows?.length) return 0
|
||||
|
||||
let changed = false
|
||||
let changedRowCount = 0
|
||||
const nextRows = methodState.detailRows.map(rawRow => {
|
||||
const mode = params.method === 'investScale' ? 'cost' : 'area'
|
||||
if (!matchesChangedScaleRow(rawRow, params.changedRowIds)) return rawRow
|
||||
const row = { ...rawRow }
|
||||
if (params.method === 'investScale') {
|
||||
const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap)
|
||||
const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap, params.sourceRowIdMap)
|
||||
|
||||
if (mode === 'cost') {
|
||||
const nextAmount = params.isOnlyCostScaleService
|
||||
? (
|
||||
typeof sourceRow?.amount === 'number'
|
||||
@ -195,34 +107,23 @@ const syncScaleMethodRows = async (params: {
|
||||
? sourceRow.amount
|
||||
: null
|
||||
)
|
||||
if (!isSameNullableNumber(row.amount, nextAmount)) {
|
||||
row.amount = nextAmount
|
||||
changed = true
|
||||
}
|
||||
const recomputed = recomputeScaleRow(row, 'cost')
|
||||
if (JSON.stringify(recomputed) !== JSON.stringify(rawRow)) {
|
||||
changed = true
|
||||
}
|
||||
return recomputed
|
||||
row.amount = isSameNullableNumber(row.amount, nextAmount) ? row.amount : nextAmount
|
||||
} else {
|
||||
const nextLandArea =
|
||||
typeof sourceRow?.landArea === 'number'
|
||||
? sourceRow.landArea
|
||||
: null
|
||||
row.landArea = isSameNullableNumber(row.landArea, nextLandArea) ? row.landArea : nextLandArea
|
||||
}
|
||||
|
||||
const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap)
|
||||
const nextLandArea =
|
||||
typeof sourceRow?.landArea === 'number'
|
||||
? sourceRow.landArea
|
||||
: null
|
||||
if (!isSameNullableNumber(row.landArea, nextLandArea)) {
|
||||
row.landArea = nextLandArea
|
||||
changed = true
|
||||
}
|
||||
const recomputed = recomputeScaleRow(row, 'area')
|
||||
if (JSON.stringify(recomputed) !== JSON.stringify(rawRow)) {
|
||||
changed = true
|
||||
}
|
||||
const recomputed = recomputeScaleDetailRow(row, mode)
|
||||
if (isSameScaleDetailRow(rawRow, recomputed, mode)) return rawRow
|
||||
changed = true
|
||||
changedRowCount += 1
|
||||
return recomputed
|
||||
})
|
||||
|
||||
if (!changed) return
|
||||
if (!changed) return 0
|
||||
|
||||
store.setServicePricingMethodState(
|
||||
params.contractId,
|
||||
@ -241,15 +142,31 @@ const syncScaleMethodRows = async (params: {
|
||||
field: params.method,
|
||||
value: getScaleMethodTotalBudgetFee(nextRows)
|
||||
})
|
||||
return changedRowCount
|
||||
}
|
||||
|
||||
export const syncContractScaleToPricing = async (contractId: string) => {
|
||||
export interface ContractScaleSyncResult {
|
||||
updatedServiceCount: number
|
||||
updatedMethodCount: number
|
||||
updatedRowCount: number
|
||||
}
|
||||
|
||||
export const syncContractScaleToPricing = async (
|
||||
contractId: string,
|
||||
options?: { changedRowIds?: string[] }
|
||||
): Promise<ContractScaleSyncResult> => {
|
||||
const store = useZxFwPricingStore()
|
||||
const kvStore = useKvStore()
|
||||
await store.loadContract(contractId)
|
||||
const currentState = store.getContractState(contractId)
|
||||
const selectedIds = Array.from(new Set((currentState?.selectedIds || []).map(id => String(id || '').trim()).filter(Boolean)))
|
||||
if (selectedIds.length === 0) return
|
||||
if (selectedIds.length === 0) {
|
||||
return {
|
||||
updatedServiceCount: 0,
|
||||
updatedMethodCount: 0,
|
||||
updatedRowCount: 0
|
||||
}
|
||||
}
|
||||
|
||||
await ensurePricingMethodDetailRowsForServices({
|
||||
contractId,
|
||||
@ -260,24 +177,51 @@ export const syncContractScaleToPricing = async (contractId: string) => {
|
||||
const htData = await kvStore.getItem<{ detailRows?: ScaleDetailRow[] }>(`ht-info-v3-${contractId}`)
|
||||
const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : []
|
||||
const sourceRowMap = buildContractScaleMap(sourceRows)
|
||||
const sourceRowIdMap = buildContractScaleIdMap(sourceRows)
|
||||
const onlyCostScaleFallbackAmount = calcOnlyCostScaleAmountFromRows(sourceRows)
|
||||
const changedRowIdSet = options?.changedRowIds?.length
|
||||
? normalizeChangedScaleRowIds(options.changedRowIds)
|
||||
: undefined
|
||||
const updatedServiceIdSet = new Set<string>()
|
||||
let updatedMethodCount = 0
|
||||
let updatedRowCount = 0
|
||||
|
||||
for (const serviceId of selectedIds) {
|
||||
const service = getServiceDictItemById(serviceId) as ServiceLite | undefined
|
||||
await syncScaleMethodRows({
|
||||
const investChangedCount = await syncScaleMethodRows({
|
||||
contractId,
|
||||
serviceId,
|
||||
method: 'investScale',
|
||||
sourceRowMap,
|
||||
sourceRowIdMap,
|
||||
changedRowIds: changedRowIdSet,
|
||||
onlyCostScaleFallbackAmount,
|
||||
isOnlyCostScaleService: service?.onlyCostScale === true
|
||||
})
|
||||
await syncScaleMethodRows({
|
||||
if (investChangedCount > 0) {
|
||||
updatedServiceIdSet.add(serviceId)
|
||||
updatedMethodCount += 1
|
||||
updatedRowCount += investChangedCount
|
||||
}
|
||||
const landChangedCount = await syncScaleMethodRows({
|
||||
contractId,
|
||||
serviceId,
|
||||
method: 'landScale',
|
||||
sourceRowMap
|
||||
sourceRowMap,
|
||||
sourceRowIdMap,
|
||||
changedRowIds: changedRowIdSet
|
||||
})
|
||||
if (landChangedCount > 0) {
|
||||
updatedServiceIdSet.add(serviceId)
|
||||
updatedMethodCount += 1
|
||||
updatedRowCount += landChangedCount
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
updatedServiceCount: updatedServiceIdSet.size,
|
||||
updatedMethodCount,
|
||||
updatedRowCount
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
TooltipModule,ClientSideRowModelApiModule ,
|
||||
RenderApiModule ,ColumnApiModule ,CellSpanModule ,RowStyleModule ,RowSelectionModule ,
|
||||
CellSelectionModule,
|
||||
ClipboardModule,
|
||||
ClipboardModule,ScrollApiModule ,
|
||||
LicenseManager,
|
||||
RowGroupingModule,
|
||||
TreeDataModule,ContextMenuModule,ValidationModule
|
||||
@ -39,7 +39,7 @@ const AG_GRID_MODULES = [
|
||||
CellStyleModule,ClientSideRowModelApiModule ,
|
||||
PinnedRowModule,RenderApiModule ,ColumnApiModule ,
|
||||
TooltipModule,
|
||||
TreeDataModule,
|
||||
TreeDataModule,ScrollApiModule ,
|
||||
AggregationModule,
|
||||
RowGroupingModule,
|
||||
CellSelectionModule,
|
||||
|
||||
49
src/sql.ts
49
src/sql.ts
@ -1025,6 +1025,13 @@ export async function exportFile(fileName: string, data: any | (() => Promise<an
|
||||
// 按模板生成最终工作簿:填充封面、目录、各分表及汇总数据。
|
||||
async function generateTemplate(data) {
|
||||
console.log(data)
|
||||
data = cloneCellValue(data);
|
||||
const resolveScaleMajorId = (scaleRow) => {
|
||||
const majorid = Number(scaleRow?.majorid);
|
||||
if (Number.isFinite(majorid)) return majorid;
|
||||
const major = Number(scaleRow?.major);
|
||||
return Number.isFinite(major) ? major : null;
|
||||
};
|
||||
// 编制说明 → 工作内容的前后默认项
|
||||
let prefixIDs = [6, 7, 8, 9, 10, 11, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27];
|
||||
let suffixIDs = [6, 7, 8, 9, 10, 11, 12, 13, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27];
|
||||
@ -1101,20 +1108,42 @@ async function generateTemplate(data) {
|
||||
let allMethods = new Set();
|
||||
let allReserveMethods = { 0: { coe: [], cid: [], coeSet: new Set() }, 4: [], 5: [] };
|
||||
let contractFeeSummary = [];
|
||||
data.scale?.forEach(sci => {
|
||||
allMajors[sci.major] = { [data.contracts.length]: sci };
|
||||
});
|
||||
const applyScaleToMajorMap = (scaleRows, slotIndex) => {
|
||||
if (!Array.isArray(scaleRows)) return;
|
||||
scaleRows.forEach(sci => {
|
||||
const scaleMajorId = resolveScaleMajorId(sci);
|
||||
if (scaleMajorId == null) return;
|
||||
if (allMajors[scaleMajorId]) {
|
||||
allMajors[scaleMajorId][slotIndex] = sci;
|
||||
} else {
|
||||
allMajors[scaleMajorId] = { [slotIndex]: sci };
|
||||
}
|
||||
});
|
||||
};
|
||||
const costRowExists = (scaleRows) => Array.isArray(scaleRows) && scaleRows.some(sci => toFiniteNumber(sci?.cost) != null);
|
||||
const setTotalCostRow = (slotIndex, cost) => {
|
||||
if (toFiniteNumber(cost) == null) return;
|
||||
const totalScaleRow = { majorid: -1, major: -1, cost, area: null };
|
||||
if (allMajors[-1]) {
|
||||
allMajors[-1][slotIndex] = totalScaleRow;
|
||||
} else {
|
||||
allMajors[-1] = { [slotIndex]: totalScaleRow };
|
||||
}
|
||||
};
|
||||
applyScaleToMajorMap(data.scale, data.contracts.length);
|
||||
const projectScaleCost = toFiniteNumber(data.scaleCost);
|
||||
if (projectScaleCost != null && (projectScaleCost !== 0 || costRowExists(data.scale))) {
|
||||
// 模板内部仍需要“总投资”行,但不把它写回导出 payload。
|
||||
setTotalCostRow(data.contracts.length, projectScaleCost);
|
||||
}
|
||||
data.contracts.forEach((ci, index) => {
|
||||
contractFeeSummary.push(`${ci.name} ${Number(ci.fee).toLocaleString()}元`);
|
||||
ci.allServiceMajors = new Set();
|
||||
// 记录allMajors
|
||||
ci.scale?.forEach(sci => {
|
||||
if (allMajors[sci.major]) {
|
||||
allMajors[sci.major][index] = sci;
|
||||
} else {
|
||||
allMajors[sci.major] = { [index]: sci };
|
||||
}
|
||||
});
|
||||
applyScaleToMajorMap(ci.scale, index);
|
||||
if (costRowExists(ci.scale)) {
|
||||
setTotalCostRow(index, addNumbers(...ci.scale.map(sci => toFiniteNumber(sci?.cost))));
|
||||
}
|
||||
ci.method1 = [];
|
||||
ci.method2 = [];
|
||||
ci.method3 = [];
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/aggridresetheader.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectworkspace.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ht/ht.vue","./src/components/ht/htadditionalworkfee.vue","./src/components/ht/htbaseinfo.vue","./src/components/ht/htconsultcategoryfactor.vue","./src/components/ht/htcontractsummary.vue","./src/components/ht/htfeeratemethodform.vue","./src/components/ht/htmajorfactor.vue","./src/components/ht/htreservefee.vue","./src/components/ht/htcard.vue","./src/components/ht/htinfo.vue","./src/components/ht/zxfw.vue","./src/components/pricing/hourlypricingpane.vue","./src/components/pricing/investmentscalepricingpane.vue","./src/components/pricing/landscalepricingpane.vue","./src/components/pricing/workloadpricingpane.vue","./src/components/shared/hourlyfeegrid.vue","./src/components/shared/htfeegrid.vue","./src/components/shared/htfeemethodgrid.vue","./src/components/shared/methodunavailablenotice.vue","./src/components/shared/servicecheckboxselector.vue","./src/components/shared/workcontentgrid.vue","./src/components/shared/xmfactorgrid.vue","./src/components/shared/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/homeentryview.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/quickcalcworkbenchview.vue","./src/components/views/zxfwview.vue","./src/components/xm/xmconsultcategoryfactor.vue","./src/components/xm/xmmajorfactor.vue","./src/components/xm/info.vue","./src/components/xm/xmcard.vue","./src/components/xm/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
{"root":["./src/main.ts","./src/sql.ts","./src/components/ui/button/index.ts","./src/components/ui/card/index.ts","./src/components/ui/scroll-area/index.ts","./src/components/ui/tooltip/index.ts","./src/lib/aggridresetheader.ts","./src/lib/contractsegment.ts","./src/lib/decimal.ts","./src/lib/diyaggridoptions.ts","./src/lib/number.ts","./src/lib/numberformat.ts","./src/lib/pricinghourlycalc.ts","./src/lib/pricingmethodtotals.ts","./src/lib/pricingpersistcontrol.ts","./src/lib/pricingpinnedrows.ts","./src/lib/pricingscalecalc.ts","./src/lib/pricingscalecolumns.ts","./src/lib/pricingscaledetail.ts","./src/lib/pricingscaledict.ts","./src/lib/pricingscalefee.ts","./src/lib/pricingscalegrid.ts","./src/lib/pricingscalelink.ts","./src/lib/pricingscalepanedata.ts","./src/lib/pricingscalepanelifecycle.ts","./src/lib/pricingscaleproject.ts","./src/lib/pricingscalerowmap.ts","./src/lib/pricingworkloadcalc.ts","./src/lib/projectworkspace.ts","./src/lib/reportexportbuilders.ts","./src/lib/utils.ts","./src/lib/workspace.ts","./src/lib/xmfactordefaults.ts","./src/lib/zwarchive.ts","./src/lib/zxfwpricingsync.ts","./src/pinia/kv.ts","./src/pinia/tab.ts","./src/pinia/zxfwpricing.ts","./src/pinia/zxfwpricinghtfee.ts","./src/pinia/zxfwpricingkeys.ts","./src/pinia/plugin/indexdb.ts","./src/pinia/plugin/types.d.ts","./src/types/pricing.ts","./src/app.vue","./src/components/ht/ht.vue","./src/components/ht/htadditionalworkfee.vue","./src/components/ht/htbaseinfo.vue","./src/components/ht/htconsultcategoryfactor.vue","./src/components/ht/htcontractsummary.vue","./src/components/ht/htfeeratemethodform.vue","./src/components/ht/htmajorfactor.vue","./src/components/ht/htreservefee.vue","./src/components/ht/htcard.vue","./src/components/ht/htinfo.vue","./src/components/ht/zxfw.vue","./src/components/pricing/hourlypricingpane.vue","./src/components/pricing/investmentscalepricingpane.vue","./src/components/pricing/landscalepricingpane.vue","./src/components/pricing/workloadpricingpane.vue","./src/components/shared/hourlyfeegrid.vue","./src/components/shared/htfeegrid.vue","./src/components/shared/htfeemethodgrid.vue","./src/components/shared/methodunavailablenotice.vue","./src/components/shared/servicecheckboxselector.vue","./src/components/shared/workcontentgrid.vue","./src/components/shared/xmfactorgrid.vue","./src/components/shared/xmcommonaggrid.vue","./src/components/ui/button/button.vue","./src/components/ui/card/card.vue","./src/components/ui/card/cardaction.vue","./src/components/ui/card/cardcontent.vue","./src/components/ui/card/carddescription.vue","./src/components/ui/card/cardfooter.vue","./src/components/ui/card/cardheader.vue","./src/components/ui/card/cardtitle.vue","./src/components/ui/scroll-area/scrollarea.vue","./src/components/ui/scroll-area/scrollbar.vue","./src/components/ui/tooltip/tooltipcontent.vue","./src/components/views/homeentryview.vue","./src/components/views/htfeemethodtypelineview.vue","./src/components/views/quickcalcworkbenchview.vue","./src/components/views/zxfwview.vue","./src/components/xm/xmconsultcategoryfactor.vue","./src/components/xm/xmmajorfactor.vue","./src/components/xm/info.vue","./src/components/xm/xmcard.vue","./src/components/xm/xminfo.vue","./src/layout/tab.vue","./src/layout/typeline.vue"],"version":"5.9.3"}
|
||||
Loading…
x
Reference in New Issue
Block a user