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