This commit is contained in:
wintsa 2026-03-24 16:43:15 +08:00
parent 8417f8d5cc
commit 693a9628bc
38 changed files with 3688 additions and 2817 deletions

View File

@ -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:[],

View File

@ -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('请先勾选至少一个合同段。')

View File

@ -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>

View File

@ -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">

View File

@ -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

View File

@ -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"

View File

@ -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' } }"

View File

@ -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>

View File

@ -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>

View File

@ -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"

View File

@ -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' } }"

View File

@ -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>

View File

@ -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">

View File

@ -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
View 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
}

View File

@ -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
}
}

View File

@ -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

View File

@ -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)
}

View File

@ -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>)

View 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]

View 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
}
})

View 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
View 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
View 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
View 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)
)

View 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()
}

View 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

View 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)
}

View 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)
})
}

View File

@ -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)
}
})
}

View 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])
}

View File

@ -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
}

View File

@ -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
}
}

View File

@ -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,

View File

@ -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 = [];

View File

@ -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"}