This commit is contained in:
wintsa 2026-03-11 17:41:08 +08:00
parent 3d26b0b259
commit 0f71fff9ac
11 changed files with 873 additions and 205 deletions

View File

@ -423,7 +423,17 @@ const columnDefs: (ColDef<DetailRow> | ColGroupDef<DetailRow>)[] = [
const totalPersonnelCount = computed(() => sumByNumber(detailRows.value, row => row.personnelCount))
const totalWorkdayCount = computed(() => sumByNumber(detailRows.value, row => row.workdayCount))
const totalServiceBudget = computed(() => sumByNumber(detailRows.value, row => calcServiceBudget(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 totalServiceBudget = computed(() => sumNullableBy(detailRows.value, row => calcServiceBudget(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',

View File

@ -59,6 +59,12 @@ interface XmBaseInfoState {
projectIndustry?: string
}
interface XmScaleState {
detailRows?: unknown[]
roughCalcEnabled?: boolean
totalAmount?: number | null
}
const STORAGE_KEY = 'ht-card-v1'
const CONTRACT_SEGMENT_FILE_EXTENSION = '.htzw'
const CONTRACT_SEGMENT_VERSION = 2
@ -68,6 +74,7 @@ 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
@ -361,6 +368,19 @@ const saveContracts = async () => {
}
}
const initializeContractScaleData = async (contractId: string) => {
const source = await kvStore.getItem<XmScaleState>(PROJECT_SCALE_KEY)
const payload: XmScaleState = {
detailRows: Array.isArray(source?.detailRows) ? cloneJson(source.detailRows) : [],
roughCalcEnabled: Boolean(source?.roughCalcEnabled),
totalAmount:
typeof source?.totalAmount === 'number' && Number.isFinite(source.totalAmount)
? source.totalAmount
: null
}
await kvStore.setItem(`${CONTRACT_KEY_PREFIX}${contractId}`, payload)
}
const normalizeContractsFromPayload = (value: unknown): ContractItem[] => {
if (!Array.isArray(value)) return []
return value
@ -795,17 +815,20 @@ const createContract = async () => {
return
}
contracts.value = [
...contracts.value,
{
const newContract: ContractItem = {
id: `ct-${Date.now()}-${Math.random().toString(16).slice(2, 6)}`,
name,
order: contracts.value.length,
createdAt: new Date().toISOString()
}
]
contracts.value = [...contracts.value, newContract]
await saveContracts()
try {
await initializeContractScaleData(newContract.id)
} catch (error) {
console.error('initialize contract scale failed:', error)
}
notify('新建成功')
closeCreateModal()
await nextTick()

View File

@ -1,7 +1,14 @@
<script setup lang="ts">
import { parseDate } from '@internationalized/date'
import { onMounted, ref, watch } from 'vue'
import { industryTypeList } from '@/sql'
import {
getMajorDictEntries,
getServiceDictEntries,
getIndustryTypeValue,
industryTypeList,
isIndustryEnabledByType,
isMajorIdInIndustryScope
} from '@/sql'
import { useKvStore } from '@/pinia/kv'
import { Calendar as CalendarIcon, CircleHelp } from 'lucide-vue-next'
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
@ -38,8 +45,28 @@ interface XmInfoState {
}
type MajorParentNode = { id: string; name: string }
type DictItemLite = {
code?: string
name?: string
defCoe?: number | null
notshowByzxflxs?: boolean
}
type FactorPersistRow = {
id: string
code: string
name: string
standardFactor: number | null
budgetValue: number | null
remark: string
path: string[]
}
type FactorPersistState = {
detailRows: FactorPersistRow[]
}
const DB_KEY = 'xm-base-info-v1'
const XM_CONSULT_CATEGORY_FACTOR_KEY = 'xm-consult-category-factor-v1'
const XM_MAJOR_FACTOR_KEY = 'xm-major-factor-v1'
const DEFAULT_PROJECT_NAME = 'xxx造价咨询服务'
const INDUSTRY_HINT_TEXT = '变更需要重置后重新选择'
const PROJECT_INIT_CHANGED_EVENT = 'xm-project-init-changed'
@ -101,6 +128,76 @@ const majorParentCodeSet = new Set(majorParentNodes.map(item => item.id))
const DEFAULT_PROJECT_INDUSTRY = majorParentNodes[0]?.id || ''
const kvStore = useKvStore()
const buildCodePath = (code: string, selfId: string, codeIdMap: Map<string, string>) => {
const parts = code.split('-').filter(Boolean)
if (!parts.length) return [selfId]
const path: string[] = []
let currentCode = parts[0]
const firstId = codeIdMap.get(currentCode)
if (firstId) path.push(firstId)
for (let i = 1; i < parts.length; i += 1) {
currentCode = `${currentCode}-${parts[i]}`
const id = codeIdMap.get(currentCode)
if (id) path.push(id)
}
if (!path.length || path[path.length - 1] !== selfId) path.push(selfId)
return path
}
const buildFactorRowsFromEntries = (entries: Array<{ id: string; item: DictItemLite }>): FactorPersistRow[] => {
const codeIdMap = new Map<string, string>()
for (const entry of entries) {
const code = String(entry.item?.code || '').trim()
if (!code) continue
codeIdMap.set(code, entry.id)
}
return entries
.map(entry => {
const code = String(entry.item?.code || '').trim()
const name = String(entry.item?.name || '').trim()
if (!code || !name) return null
const standardFactor =
typeof entry.item?.defCoe === 'number' && Number.isFinite(entry.item.defCoe)
? entry.item.defCoe
: null
return {
id: entry.id,
code,
name,
standardFactor,
budgetValue: standardFactor,
remark: '',
path: buildCodePath(code, entry.id, codeIdMap)
}
})
.filter((item): item is FactorPersistRow => Boolean(item))
}
const initializeProjectFactorStates = async (industry: string) => {
const industryType = getIndustryTypeValue(industry)
const consultEntries = getServiceDictEntries()
.map(({ id, item }) => ({ id, item: item as DictItemLite }))
.filter(({ item }) => {
if (item.notshowByzxflxs === true) return false
return isIndustryEnabledByType(item as Record<string, unknown>, industryType)
})
const majorEntries = getMajorDictEntries()
.map(({ id, item }) => ({ id, item: item as DictItemLite }))
.filter(({ id, item }) => item.notshowByzxflxs !== true && isMajorIdInIndustryScope(id, industry))
const consultPayload: FactorPersistState = {
detailRows: buildFactorRowsFromEntries(consultEntries)
}
const majorPayload: FactorPersistState = {
detailRows: buildFactorRowsFromEntries(majorEntries)
}
await Promise.all([
kvStore.setItem(XM_CONSULT_CATEGORY_FACTOR_KEY, consultPayload),
kvStore.setItem(XM_MAJOR_FACTOR_KEY, majorPayload)
])
}
const saveToIndexedDB = async () => {
try {
const payload: XmInfoState = {
@ -195,6 +292,11 @@ const createProject = async () => {
isProjectInitialized.value = true
showCreateDialog.value = false
await saveToIndexedDB()
try {
await initializeProjectFactorStates(selectedIndustry)
} catch (error) {
console.error('initializeProjectFactorStates failed:', error)
}
window.dispatchEvent(new CustomEvent<boolean>(PROJECT_INIT_CHANGED_EVENT, { detail: true }))
}

View File

@ -582,6 +582,14 @@ const formatEditableNumber = (params: any) => {
return formatThousandsFlexible(params.value, 3)
}
const formatEditableRatio2 = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '请输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 2)
}
const formatConsultCategoryFactor = (params: any) => {
return formatEditableNumber(params)
}
@ -878,8 +886,8 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableRatio2
},
{
headerName: '合计',
@ -960,9 +968,20 @@ const autoGroupColumnDef: ColDef = {
const totalAmount = computed(() => sumByNumber(detailRows.value, row => row.amount))
const totalBudgetFeeBasic = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
const totalBudgetFeeOptional = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(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 totalBudgetFeeBasic = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
const totalBudgetFeeOptional = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
const totalBudgetFee = computed(() => sumNullableBy(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => {
return [
{
@ -1284,16 +1303,14 @@ const processCellFromClipboard = (params: any) => {
<h3 class="text-sm font-semibold text-foreground">投资规模明细</h3>
<div v-if="isMutipleService" class="flex items-center gap-2">
<span class="text-xs text-muted-foreground">项目数量</span>
<NumberFieldRoot
v-model="projectCount"
:min="1"
:step="1"
<NumberFieldRoot v-model="projectCount" :min="1" :step="1"
class="inline-flex items-center rounded-md border bg-background"
@update:model-value="value => void applyProjectCountChange(value)"
>
<NumberFieldDecrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-</NumberFieldDecrement>
@update:model-value="value => void applyProjectCountChange(value)">
<NumberFieldDecrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">-
</NumberFieldDecrement>
<NumberFieldInput class="h-7 w-14 border-x bg-transparent px-2 text-center text-xs outline-none" />
<NumberFieldIncrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+</NumberFieldIncrement>
<NumberFieldIncrement class="cursor-pointer px-2 py-1 text-xs text-muted-foreground hover:bg-muted">+
</NumberFieldIncrement>
</NumberFieldRoot>
</div>
</div>
@ -1304,7 +1321,8 @@ const processCellFromClipboard = (params: any) => {
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认清空当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将清空当前投资规模明细是否继续
@ -1326,7 +1344,8 @@ const processCellFromClipboard = (params: any) => {
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/45" />
<AlertDialogContent class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border bg-background p-5 shadow-xl">
<AlertDialogTitle class="text-base font-semibold">确认覆盖当前明细</AlertDialogTitle>
<AlertDialogDescription class="mt-2 text-sm text-muted-foreground">
将使用合同默认数据覆盖当前投资规模明细是否继续

View File

@ -457,6 +457,14 @@ const formatEditableNumber = (params: any) => {
return formatThousandsFlexible(params.value, 3)
}
const formatEditableRatio2 = (params: any) => {
if (!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')) {
return '请输入'
}
if (params.value == null) return ''
return formatThousandsFlexible(params.value, 2)
}
const formatConsultCategoryFactor = (params: any) => {
return formatEditableNumber(params)
}
@ -731,8 +739,8 @@ const columnDefs: Array<ColDef<DetailRow> | ColGroupDef<DetailRow>> = [
'editable-cell-empty': params =>
!params.node?.group && !params.node?.rowPinned && (params.value == null || params.value === '')
},
valueParser: params => parseNumberOrNull(params.newValue, { precision: 3 }),
valueFormatter: formatEditableNumber
valueParser: params => parseNumberOrNull(params.newValue, { precision: 2 }),
valueFormatter: formatEditableRatio2
},
{
headerName: '合计',
@ -814,9 +822,20 @@ const totalBenchmarkBudgetBasic = computed(() => sumByNumber(detailRows.value, r
const totalBenchmarkBudgetOptional = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.optional))
const totalBenchmarkBudget = computed(() => sumByNumber(detailRows.value, row => getBenchmarkBudgetSplitByLandArea(row)?.total))
const totalBudgetFeeBasic = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
const totalBudgetFeeOptional = computed(() => sumByNumber(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
const totalBudgetFee = computed(() => sumByNumber(detailRows.value, row => getBudgetFee(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 totalBudgetFeeBasic = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.basic))
const totalBudgetFeeOptional = computed(() => sumNullableBy(detailRows.value, row => getBudgetFeeSplit(row)?.optional))
const totalBudgetFee = computed(() => sumNullableBy(detailRows.value, row => getBudgetFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',

View File

@ -400,7 +400,18 @@ const columnDefs: ColDef<DetailRow>[] = [
const totalWorkload = computed(() => sumByNumber(detailRows.value, row => row.workload))
const totalBasicFee = computed(() => sumByNumber(detailRows.value, row => calcBasicFee(row)))
const totalServiceFee = computed(() => sumByNumber(detailRows.value, row => calcServiceFee(row)))
const sumNullableBy = <T>(rows: T[], pick: (row: T) => unknown): number | null => {
let hasValid = false
const total = sumByNumber(rows, row => {
const value = Number(pick(row))
if (!Number.isFinite(value)) return null
hasValid = true
return value
})
return hasValid ? total : null
}
const totalServiceFee = computed(() => sumNullableBy(detailRows.value, row => calcServiceFee(row)))
const pinnedTopRowData = computed(() => [
{
id: 'pinned-total-row',

View File

@ -2,7 +2,7 @@
import { computed, defineComponent, h, nextTick, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import type { ComponentPublicInstance, PropType } from 'vue'
import { AgGridVue } from 'ag-grid-vue3'
import type { ColDef, GridOptions, ICellRendererParams } from 'ag-grid-community'
import type { ColDef, GridOptions, ICellRendererParams, IHeaderParams } from 'ag-grid-community'
import { myTheme, gridOptions } from '@/lib/diyAgGridOptions'
import { AG_GRID_LOCALE_CN } from '@ag-grid-community/locale'
import { addNumbers } from '@/lib/decimal'
@ -14,7 +14,7 @@ import {
getPricingMethodTotalsForServices,
type PricingMethodTotals
} from '@/lib/pricingMethodTotals'
import { Pencil, Eraser, Trash2 } from 'lucide-vue-next'
import { Pencil, Eraser, Trash2, CircleHelp } from 'lucide-vue-next'
import {
AlertDialogAction,
AlertDialogCancel,
@ -45,6 +45,7 @@ interface DetailRow {
id: string
code: string
name: string
process: number | null
investScale: number | null
landScale: number | null
workload: number | null
@ -146,6 +147,7 @@ const detailRows = computed<DetailRow[]>(() =>
id: String(row.id || ''),
code: row.code || '',
name: row.name || '',
process: String(row.id || '') === fixedBudgetRow.id ? null : (Number(row.process) === 1 ? 1 : 0),
investScale: typeof row.investScale === 'number' ? row.investScale : null,
landScale: typeof row.landScale === 'number' ? row.landScale : null,
workload: typeof row.workload === 'number' ? row.workload : null,
@ -165,6 +167,7 @@ const getCurrentContractState = (): ZxFwViewState => {
id: String(row.id || ''),
code: row.code || '',
name: row.name || '',
process: String(row.id || '') === fixedBudgetRow.id ? null : (Number(row.process) === 1 ? 1 : 0),
investScale: typeof row.investScale === 'number' ? row.investScale : null,
landScale: typeof row.landScale === 'number' ? row.landScale : null,
workload: typeof row.workload === 'number' ? row.workload : null,
@ -354,7 +357,14 @@ const numericParser = (newValue: any): number | null => {
return parseNumberOrNull(newValue, { precision: 3 })
}
const valueOrZero = (v: number | null | undefined) => (typeof v === 'number' ? v : 0)
const isFiniteNumberValue = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
const validValues = values.filter(isFiniteNumberValue)
if (validValues.length === 0) return null
return addNumbers(...validValues)
}
const getServiceMethodTypeById = (serviceId: string) => {
const type = serviceById.value.get(serviceId)?.type
@ -393,22 +403,22 @@ const sanitizePricingFieldsByService = (
const getMethodTotalFromRows = (
rows: DetailRow[],
field: 'investScale' | 'landScale' | 'workload' | 'hourly'
) =>
rows.reduce((sum, row) => {
if (isFixedRow(row)) return sum
return addNumbers(sum, valueOrZero(row[field]))
}, 0)
) => sumNullableNumbers(
rows
.filter(row => !isFixedRow(row))
.map(row => row[field])
)
const getMethodTotal = (field: 'investScale' | 'landScale' | 'workload' | 'hourly') =>
getMethodTotalFromRows(detailRows.value, field)
const getFixedRowSubtotal = () =>
addNumbers(
sumNullableNumbers([
getMethodTotal('investScale'),
getMethodTotal('landScale'),
getMethodTotal('workload'),
getMethodTotal('hourly')
)
])
const getPricingPaneStorageKeys = (serviceId: string) =>
zxFwPricingStore.getServicePricingStorageKeys(props.contractId, serviceId)
@ -469,7 +479,7 @@ const clearRowValues = async (row: DetailRow) => {
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: addNumbers(nextInvestScale, nextLandScale, nextWorkload, nextHourly)
subtotal: sumNullableNumbers([nextInvestScale, nextLandScale, nextWorkload, nextHourly])
}
: item
)
@ -532,6 +542,60 @@ const ActionCellRenderer = defineComponent({
}
})
const ProcessCellRenderer = defineComponent({
name: 'ProcessCellRenderer',
props: {
params: {
type: Object as PropType<ICellRendererParams<DetailRow>>,
required: true
}
},
setup(props) {
return () => {
const row = props.params.data
if (!row || isFixedRow(row)) return null
const checked = row.process === 1
const onToggle = (event: Event) => {
event.stopPropagation()
const target = event.target as HTMLInputElement | null
void props.params.context?.onToggleProcess?.(row.id, Boolean(target?.checked))
}
return h('div', { class: 'flex items-center justify-center w-full' }, [
h('input', {
type: 'checkbox',
checked,
class: 'cursor-pointer',
onChange: onToggle
})
])
}
}
})
const ProcessHeaderRenderer = defineComponent({
name: 'ProcessHeaderRenderer',
props: {
params: {
type: Object as PropType<IHeaderParams>,
required: true
}
},
setup(props) {
const tooltipText = '默认为编制,勾选为审核'
props.params.setTooltip?.(tooltipText, () => true)
return () =>
h('div', { class: 'flex items-center justify-center gap-1 w-full' }, [
h('span', props.params.displayName || '工作环节'),
h('span', { class: 'inline-flex items-center pointer-events-auto' }, [
h(CircleHelp, {
size: 20,
class: 'text-muted-foreground pointer-events-none'
})
])
])
}
})
const columnDefs: ColDef<DetailRow>[] = [
{
headerName: '编码',
@ -557,6 +621,29 @@ const columnDefs: ColDef<DetailRow>[] = [
return params.data.name
}
},
{
headerName: '工作环节',
field: 'process',
headerClass: 'ag-center-header',
minWidth: 90,
maxWidth: 110,
flex: 1,
editable: false,
sortable: false,
filter: false,
cellStyle: {
textAlign: 'center',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
},
valueGetter: params => {
if (!params.data || isFixedRow(params.data)) return null
return params.data.process === 1 ? 1 : 0
},
headerComponent: ProcessHeaderRenderer,
cellRenderer: ProcessCellRenderer
},
{
headerName: '投资规模法',
field: 'investScale',
@ -639,12 +726,12 @@ const columnDefs: ColDef<DetailRow>[] = [
valueGetter: params => {
if (!params.data) return null
if (isFixedRow(params.data)) return getFixedRowSubtotal()
return addNumbers(
valueOrZero(params.data.investScale),
valueOrZero(params.data.landScale),
valueOrZero(params.data.workload),
valueOrZero(params.data.hourly)
)
return sumNullableNumbers([
params.data.investScale,
params.data.landScale,
params.data.workload,
params.data.hourly
])
},
valueFormatter: params => (params.value == null ? '' : formatThousandsFlexible(params.value, 3))
},
@ -666,6 +753,25 @@ const detailGridOptions: GridOptions<DetailRow> = {
...gridOptions,
treeData: false,
getDataPath: undefined,
context: {
onToggleProcess: async (rowId: string, checked: boolean) => {
const currentState = getCurrentContractState()
let changed = false
const nextRows = currentState.detailRows.map(row => {
if (isFixedRow(row) || String(row.id) !== String(rowId)) return row
changed = true
return {
...row,
process: checked ? 1 : 0
}
})
if (!changed) return
await setCurrentContractState({
...currentState,
detailRows: nextRows
})
}
},
onCellClicked: async params => {
if (params.colDef.field !== 'actions' || !params.data || isFixedRow(params.data)) return
const target = params.event?.target as HTMLElement | null
@ -698,7 +804,7 @@ const applyFixedRowTotals = (rows: DetailRow[]) => {
landScale: nextLandScale,
workload: nextWorkload,
hourly: nextHourly,
subtotal: addNumbers(nextInvestScale, nextLandScale, nextWorkload, nextHourly)
subtotal: sumNullableNumbers([nextInvestScale, nextLandScale, nextWorkload, nextHourly])
}
: row
)
@ -779,7 +885,7 @@ const applySelection = async (codes: string[]) => {
const existingMap = new Map(currentState.detailRows.map(row => [row.id, row]))
const baseRows: DetailRow[] = uniqueIds
.map(id => {
.map<DetailRow | null>(id => {
const dictItem = serviceById.value.get(id)
if (!dictItem) return null
@ -794,13 +900,14 @@ const applySelection = async (codes: string[]) => {
id: old?.id || id,
code: dictItem.code,
name: dictItem.name,
process: old?.process === 1 ? 1 : 0,
investScale: nextValues.investScale,
landScale: nextValues.landScale,
workload: nextValues.workload,
hourly: nextValues.hourly
}
})
.filter((row): row is DetailRow => Boolean(row))
.filter((row): row is DetailRow => row !== null)
const orderMap = new Map(serviceDict.value.map((item, index) => [item.id, index]))
baseRows.sort((a, b) => (orderMap.get(a.id) || 0) - (orderMap.get(b.id) || 0))
@ -810,6 +917,7 @@ const applySelection = async (codes: string[]) => {
id: fixedOld?.id || fixedBudgetRow.id,
code: fixedBudgetRow.code,
name: fixedBudgetRow.name,
process: null,
investScale: typeof fixedOld?.investScale === 'number' ? fixedOld.investScale : null,
landScale: typeof fixedOld?.landScale === 'number' ? fixedOld.landScale : null,
workload: typeof fixedOld?.workload === 'number' ? fixedOld.workload : null,
@ -983,6 +1091,7 @@ const initializeContractState = async () => {
id: fixedBudgetRow.id,
code: fixedBudgetRow.code,
name: fixedBudgetRow.name,
process: null,
investScale: null,
landScale: null,
workload: null,

View File

@ -22,7 +22,8 @@ import {
AlertDialogTrigger,
} from 'reka-ui'
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
import { exportFile, serviceList } from '@/sql'
import { addNumbers, roundTo } from '@/lib/decimal'
import { exportFile, serviceList, additionalWorkList } from '@/sql'
interface DataEntry {
key: string
@ -59,12 +60,14 @@ type XmInfoLike = {
interface ScaleRowLike {
id: string
amount?: unknown
landArea?: unknown
amount: number | null
landArea: number | null
}
interface XmInfoStorageLike extends XmInfoLike {
detailRows?: ScaleRowLike[]
detailRows?: ScaleRowLike[],
totalAmount?: number,
roughCalcEnabled?: boolean
}
interface XmScaleStorageLike {
detailRows?: ScaleRowLike[]
@ -78,6 +81,7 @@ interface ContractCardItem {
interface ZxFwRowLike {
id: string
process?: unknown
subtotal?: unknown
investScale?: unknown
landScale?: unknown
@ -86,6 +90,8 @@ interface ZxFwRowLike {
}
interface ZxFwStorageLike {
selectedIds?: string[]
selectedCodes?: string[]
detailRows?: ZxFwRowLike[]
}
@ -97,6 +103,28 @@ interface ScaleMethodRowLike extends ScaleRowLike {
budgetFeeOptional?: unknown
consultCategoryFactor?: unknown
majorFactor?: unknown
workStageFactor?: unknown
workRatio?: unknown
remark?: unknown
}
interface HtFeeMainRowLike {
id?: unknown
name?: unknown
}
interface RateMethodRowLike {
rate?: unknown
budgetFee?: unknown
}
interface QuantityMethodRowLike {
id?: unknown
feeItem?: unknown
unit?: unknown
quantity?: unknown
unitPrice?: unknown
budgetFee?: unknown
remark?: unknown
}
@ -120,7 +148,11 @@ interface HourlyMethodRowLike {
}
interface DetailRowsStorageLike<T> {
detailRows?: T[]
detailRows?: T[],
roughCalcEnabled?: boolean,
totalAmount?: number,
}
interface FactorRowLike {
@ -132,8 +164,8 @@ interface FactorRowLike {
interface ExportScaleRow {
major: number
cost: number
area: number
cost: number | null
area: number | null
}
interface ExportMethod1Detail {
@ -250,8 +282,63 @@ interface ExportContract {
serviceCoes: ExportServiceCoe[]
majorCoes: ExportMajorCoe[]
services: ExportService[]
addtional: []
reserve: []
addtional: ExportAdditional | null
reserve: ExportReserve | null
}
interface ExportRichTextCode {
richText: Array<{
text: string
font?: Record<string, unknown>
}>
}
interface ExportMethod0 {
coe: number
fee: number
}
interface ExportMethod5Detail {
name: string
unit: string
amount: number
price: number
fee: number
remark: string
}
interface ExportMethod5 {
fee: number
det: ExportMethod5Detail[]
}
interface ExportAdditionalDetail {
id: number
code: ExportRichTextCode
ref?: ExportRichTextCode
name: string
fee: number
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
interface ExportAdditional {
code: ExportRichTextCode
ref?: ExportRichTextCode
name: string
fee: number
det: ExportAdditionalDetail[]
}
interface ExportReserve {
code: ExportRichTextCode
ref?: ExportRichTextCode
name: string
fee: number
m0?: ExportMethod0
m4?: ExportMethod4
m5?: ExportMethod5
}
interface ExportReportPayload {
@ -872,6 +959,15 @@ const getExpertIdFromRowId = (value: string): number | 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
@ -886,12 +982,12 @@ const buildProjectServiceCoes = (rows: FactorRowLike[] | undefined): ExportServi
return rows
.map(row => {
const serviceid = toSafeInteger(row.id)
if (serviceid == null) return null
const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor) ?? 0
if (serviceid == null || row.budgetValue == null) return null
const coe = toFiniteNumber(row.budgetValue)
return {
serviceid,
coe,
remark: typeof row.remark === 'string' ? row.remark : ''
remark: row.remark
}
})
.filter((item): item is ExportServiceCoe => Boolean(item))
@ -902,42 +998,44 @@ const buildProjectMajorCoes = (rows: FactorRowLike[] | undefined): ExportMajorCo
return rows
.map(row => {
const majorid = toSafeInteger(row.id)
if (majorid == null) return null
const coe = toFiniteNumber(row.budgetValue) ?? toFiniteNumber(row.standardFactor) ?? 0
if (majorid == null || row.budgetValue == null) return null
const coe = toFiniteNumber(row.budgetValue)
return {
majorid,
coe,
remark: typeof row.remark === 'string' ? row.remark : ''
remark: row.remark
}
})
.filter((item): item is ExportMajorCoe => Boolean(item))
}
const buildScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] => {
const toExportScaleRows = (rows: ScaleRowLike[] | undefined): ExportScaleRow[] => {
if (!Array.isArray(rows)) return []
return rows
.map(row => {
const major = toSafeInteger(row.id)
const cost = toFiniteNumber(row.amount)
const area = toFiniteNumber(row.landArea)
if (major == null || (cost == null && area == null)) return null
if (row.id == null || (row.amount == null && row.landArea == null)) return null
return {
major,
cost: cost ?? 0,
area: area ?? 0
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 det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
if (major == null || row.budgetFee ==null) return null
const cost = toFiniteNumber(row.amount)
const basicFee = toFiniteNumber(row.budgetFee)
if (basicFee != null) hasTotalValue = true
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
const remark = typeof row.remark === 'string' ? row.remark : ''
@ -958,15 +1056,16 @@ const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | n
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
processCoe: 1,
proportion: 1,
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
proportion: toFiniteNumber(row.workRatio) ?? 1,
fee: basicFee ?? 0,
remark
}
})
.filter((item): item is ExportMethod1Detail => Boolean(item))
if (det.length === 0) return null
if (det.length === 0 || !hasTotalValue) return null
console.log(det)
return {
cost: sumNumbers(det.map(item => item.cost)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
@ -979,12 +1078,14 @@ const buildMethod1 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod1 | n
const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | null => {
if (!Array.isArray(rows)) return null
let hasTotalValue = false
const det = rows
.map(row => {
const major = toSafeInteger(row.id)
if (major == null) return null
if (major == null || row.budgetFee ==null) return null
const area = toFiniteNumber(row.landArea)
const basicFee = toFiniteNumber(row.budgetFee)
if (basicFee != null) hasTotalValue = true
const basicFeeBasic = toFiniteNumber(row.budgetFeeBasic)
const basicFeeOptional = toFiniteNumber(row.budgetFeeOptional)
const remark = typeof row.remark === 'string' ? row.remark : ''
@ -1005,15 +1106,15 @@ const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | n
basicFee_optional: basicFeeOptional ?? 0,
serviceCoe: toFiniteNumberOrZero(row.consultCategoryFactor),
majorCoe: toFiniteNumberOrZero(row.majorFactor),
processCoe: 1,
proportion: 1,
processCoe: toFiniteNumber(row.workStageFactor) ?? 1,
proportion: toFiniteNumber(row.workRatio) ?? 1,
fee: basicFee ?? 0,
remark
}
})
.filter((item): item is ExportMethod2Detail => Boolean(item))
if (det.length === 0) return null
if (det.length === 0 || !hasTotalValue) return null
return {
area: sumNumbers(det.map(item => item.area)),
basicFee: sumNumbers(det.map(item => item.basicFee)),
@ -1026,6 +1127,7 @@ const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined): ExportMethod2 | n
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)
@ -1033,6 +1135,7 @@ const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3
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
@ -1048,7 +1151,7 @@ const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3
})
.filter((item): item is ExportMethod3Detail => Boolean(item))
if (det.length === 0) return null
if (det.length === 0 || !hasTotalValue) return null
return {
basicFee: sumNumbers(det.map(item => item.basicFee)),
fee: sumNumbers(det.map(item => item.fee)),
@ -1058,6 +1161,7 @@ const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined): ExportMethod3
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)
@ -1065,6 +1169,7 @@ const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 |
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
@ -1079,7 +1184,7 @@ const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 |
})
.filter((item): item is ExportMethod4Detail => Boolean(item))
if (det.length === 0) return null
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)),
@ -1089,26 +1194,182 @@ const buildMethod4 = (rows: HourlyMethodRowLike[] | undefined): ExportMethod4 |
}
const buildServiceFee = (
row: ZxFwRowLike,
row: ZxFwRowLike | null | undefined,
method1: ExportMethod1 | null,
method2: ExportMethod2 | null,
method3: ExportMethod3 | null,
method4: ExportMethod4 | null
) => {
const subtotal = toFiniteNumber(row.subtotal)
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)
toFiniteNumber(row?.investScale),
toFiniteNumber(row?.landScale),
toFiniteNumber(row?.workload),
toFiniteNumber(row?.hourly)
])
}
const createRichTextCode = (...parts: string[]): ExportRichTextCode => ({
richText: parts
.map(item => String(item || '').trim())
.filter(Boolean)
.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 : ''
const hasValue =
quantity != null ||
unitPrice != null ||
fee != null ||
isNonEmptyString(name) ||
isNonEmptyString(unit) ||
isNonEmptyString(remark)
if (!hasValue) return null
return {
name,
unit,
amount: quantity ?? 0,
price: unitPrice ?? 0,
fee: fee ?? 0,
remark
}
})
.filter((item): item is ExportMethod5Detail => Boolean(item))
if (det.length === 0) return null
return {
fee: subtotalFee,
det
}
}
const normalizeHtFeeMainRows = (rows: HtFeeMainRowLike[] | undefined) => {
if (!Array.isArray(rows)) return [] as Array<{ id: string; name: string }>
return rows
.map(row => {
const id = String(row?.id || '').trim()
if (!id) return null
return {
id,
name: typeof row?.name === 'string' ? row.name : ''
}
})
.filter((item): item is { id: string; name: string } => Boolean(item))
}
const loadHtFeeMethodsByRow = async (mainStorageKey: string, rowId: string) => {
const [rateState, hourlyState, quantityState] = await Promise.all([
zxFwPricingStore.loadHtFeeMethodState<RateMethodRowLike>(mainStorageKey, rowId, 'rate-fee'),
zxFwPricingStore.loadHtFeeMethodState<DetailRowsStorageLike<HourlyMethodRowLike>>(mainStorageKey, rowId, 'hourly-fee'),
zxFwPricingStore.loadHtFeeMethodState<DetailRowsStorageLike<QuantityMethodRowLike>>(mainStorageKey, rowId, 'quantity-unit-price-fee')
])
const m0 = buildMethod0(rateState)
const m4 = buildMethod4(Array.isArray(hourlyState?.detailRows) ? hourlyState?.detailRows : undefined)
const m5 = buildMethod5(Array.isArray(quantityState?.detailRows) ? quantityState?.detailRows : undefined)
if (!m0 && !m4 && !m5) return null
return {
fee: sumNumbers([m0?.fee, m4?.fee, m5?.fee]),
m0,
m4,
m5
}
}
const buildAdditionalDetailCode = (index: number, name: string): ExportRichTextCode => {
const dictionaryIndex = additionalWorkList.findIndex(item => item === name)
const useIndex = dictionaryIndex >= 0 ? dictionaryIndex : index
const suffix = useIndex === 0 ? 'F' : useIndex === 1 ? 'X' : String(useIndex + 1)
return createRichTextCode('C', suffix)
}
const buildAdditionalExport = async (contractId: string): Promise<ExportAdditional | null> => {
const storageKey = `htExtraFee-${contractId}-additional-work`
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(storageKey)
const rows = normalizeHtFeeMainRows(mainState?.detailRows)
if (rows.length === 0) return null
const det = (
await Promise.all(
rows.map(async (row, index) => {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
if (!methodPayload) return null
const item: ExportAdditionalDetail = {
id: index,
code: buildAdditionalDetailCode(index, row.name),
name: row.name || `附加工作-${index + 1}`,
fee: methodPayload.fee
}
item.ref = item.code
if (methodPayload.m0) item.m0 = methodPayload.m0
if (methodPayload.m4) item.m4 = methodPayload.m4
if (methodPayload.m5) item.m5 = methodPayload.m5
return item
})
)
).filter((item): item is ExportAdditionalDetail => Boolean(item))
if (det.length === 0) return null
const addtionalCode = createRichTextCode('C', 'C')
return {
code: addtionalCode,
ref: addtionalCode,
name: '附加工作',
fee: sumNumbers(det.map(item => item.fee)),
det
}
}
const buildReserveExport = async (contractId: string): Promise<ExportReserve | null> => {
const storageKey = `htExtraFee-${contractId}-reserve`
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(storageKey)
const rows = normalizeHtFeeMainRows(mainState?.detailRows)
if (rows.length === 0) return null
for (const row of rows) {
const methodPayload = await loadHtFeeMethodsByRow(storageKey, row.id)
if (!methodPayload) continue
const reserve: ExportReserve = {
code: createRichTextCode('Y', 'B'),
name: row.name || '预备费',
fee: methodPayload.fee
}
reserve.ref = reserve.code
if (methodPayload.m0) reserve.m0 = methodPayload.m0
if (methodPayload.m4) reserve.m4 = methodPayload.m4
if (methodPayload.m5) reserve.m5 = methodPayload.m5
return reserve
}
return null
}
const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const [projectInfoRaw, projectScaleRaw, consultCategoryFactorRaw, majorFactorRaw, contractCardsRaw] = await Promise.all([
kvStore.getItem<XmInfoLike>(PROJECT_INFO_DB_KEY),
@ -1120,8 +1381,14 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const projectInfo = projectInfoRaw || {}
const projectScaleSource = projectScaleRaw || {}
const projectScale = buildScaleRows(projectScaleSource.detailRows)
const projectScaleCost = sumNumbers(projectScale.map(item => item.cost))
const projectScale = projectScaleSource.roughCalcEnabled ? [] : toExportScaleRows(projectScaleSource.detailRows)
const projectScaleCost = toFiniteNumber(projectScaleSource.totalAmount) ?? sumNumbers(projectScale.map(item => item.cost))
projectScale.push({
major: -1, cost: projectScaleCost,
area: null
})
const projectServiceCoes = buildProjectServiceCoes(consultCategoryFactorRaw?.detailRows)
const projectMajorCoes = buildProjectMajorCoes(majorFactorRaw?.detailRows)
const projectName = isNonEmptyString(projectInfo.projectName) ? projectInfo.projectName.trim() : '造价项目'
@ -1139,21 +1406,59 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
for (let index = 0; index < contractCards.length; index++) {
const contract = contractCards[index]
const contractId = contract.id
const [htInfoRaw, zxFwRaw] = await Promise.all([
await zxFwPricingStore.loadContract(contractId)
const [htInfoRaw, zxFwRaw, htConsultCategoryFactorRaw, htMajorFactorRaw] = await Promise.all([
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}`),
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(`ht-major-factor-v1-${contractId}`)
])
const zxRows = Array.isArray(zxFwRaw?.detailRows) ? zxFwRaw.detailRows : []
const fixedRow = zxRows.find(row => row.id === 'fixed-budget-c')
const serviceRows = zxRows.filter(row => row.id !== 'fixed-budget-c' && hasServiceId(String(row.id)))
const contractState = zxFwPricingStore.getContractState(contractId)
const zxRowsFromStore: ZxFwRowLike[] = Array.isArray(contractState?.detailRows)
? contractState.detailRows.map(row => ({
id: String(row.id || ''),
process: row.process,
subtotal: row.subtotal,
investScale: row.investScale,
landScale: row.landScale,
workload: row.workload,
hourly: row.hourly
}))
: []
const zxRowsFromKv = Array.isArray(zxFwRaw?.detailRows) ? zxFwRaw.detailRows : []
const zxRows = zxRowsFromStore.length > 0 ? zxRowsFromStore : zxRowsFromKv
const selectedIdsFromStore = Array.isArray(contractState?.selectedIds)
? contractState.selectedIds.map(id => String(id || '').trim()).filter(Boolean)
: []
const selectedIdsFromKv = Array.isArray(zxFwRaw?.selectedIds)
? zxFwRaw.selectedIds.map(id => String(id || '').trim()).filter(Boolean)
: []
const selectedIds = Array.from(new Set([...selectedIdsFromStore, ...selectedIdsFromKv])).filter(hasServiceId)
let fixedRow: ZxFwRowLike | undefined
const serviceRowMap = new Map<string, ZxFwRowLike>()
for (const row of zxRows) {
const rowId = String(row?.id || '').trim()
if (!rowId) continue
if (rowId === 'fixed-budget-c') {
fixedRow = row
continue
}
if (!hasServiceId(rowId)) continue
serviceRowMap.set(rowId, row)
}
const fallbackServiceIds = Array.from(serviceRowMap.keys())
const serviceIdTexts = sortServiceIdsByDict(
(selectedIds.length > 0 ? selectedIds : fallbackServiceIds).filter(hasServiceId)
)
const services = (
await Promise.all(
serviceRows.map(async row => {
const serviceIdText = String(row.id)
serviceIdTexts.map(async serviceIdText => {
const serviceId = toSafeInteger(serviceIdText)
if (serviceId == null) return null
const sourceRow = serviceRowMap.get(serviceIdText)
const [method1State, method2State, method3State, method4State] = await Promise.all([
zxFwPricingStore.loadServicePricingMethodState<ScaleMethodRowLike>(contractId, serviceIdText, 'investScale'),
@ -1170,11 +1475,11 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const method2 = buildMethod2(method2Raw?.detailRows)
const method3 = buildMethod3(method3Raw?.detailRows)
const method4 = buildMethod4(method4Raw?.detailRows)
const fee = buildServiceFee(row, method1, method2, method3, method4)
const fee = buildServiceFee(sourceRow, method1, method2, method3, method4)
const process = Number(sourceRow?.process) === 1 ? 1 : 0
const service: ExportService = {
id: serviceId,
process: 0,
process,
fee
}
if (method1) service.method1 = method1
@ -1195,9 +1500,21 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
toFiniteNumber(fixedRow?.hourly)
])
const serviceFee = fixedSubtotal ?? (serviceFeeSum !== 0 ? serviceFeeSum : fixedMethodSum)
const addtionalFee = 0
const reserveFee = 0
const contractFee = serviceFee + addtionalFee + reserveFee
const [addtional, reserve] = await Promise.all([
buildAdditionalExport(contractId),
buildReserveExport(contractId)
])
const addtionalFee = addtional ? addtional.fee : 0
const reserveFee = reserve ? reserve.fee : 0
const contractFee = roundTo(addNumbers(serviceFee, addtionalFee, reserveFee), 3)
const contractScale = htInfoRaw?.roughCalcEnabled ? [] : toExportScaleRows(htInfoRaw?.detailRows)
contractScale.push({
major: -1, cost: contractFee,
area: null
})
const contractServiceCoesRaw = buildProjectServiceCoes(htConsultCategoryFactorRaw?.detailRows)
const contractMajorCoesRaw = buildProjectMajorCoes(htMajorFactorRaw?.detailRows)
contracts.push({
name: isNonEmptyString(contract.name) ? contract.name : `合同段-${index + 1}`,
@ -1205,12 +1522,12 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
addtionalFee,
reserveFee,
fee: contractFee,
scale: buildScaleRows(htInfoRaw?.detailRows),
serviceCoes: projectServiceCoes.map(item => ({ ...item })),
majorCoes: projectMajorCoes.map(item => ({ ...item })),
scale: contractScale,
serviceCoes: contractServiceCoesRaw,
majorCoes: contractMajorCoesRaw,
services,
addtional: [],
reserve: []
addtional,
reserve
})
}
@ -1273,11 +1590,12 @@ const exportReport = async () => {
try {
const now = new Date()
const payload = await buildExportReportPayload()
console.log(payload)
const fileName = `${sanitizeFileNamePart(payload.name)}-报表-${formatExportTimestamp(now)}`
await exportFile(fileName, payload)
} catch (error) {
console.error('export report failed:', error)
window.alert('导出报表失败,请重试。')
// window.alert('')
} finally {
dataMenuOpen.value = false
}

View File

@ -32,8 +32,24 @@ export const gridOptions: GridOptions = {
groupDefaultExpanded: -1,
suppressFieldDotNotation: true,
// rowData 更新后通过稳定 ID 维持展开状态和编辑上下文。
getRowId: params => String(params.data?.id ?? params.data?.path?.join('/') ?? ''),
getDataPath: data => data.path,
getRowId: params => {
const id = params.data?.id
if (id != null && String(id).trim()) return String(id)
const path = Array.isArray(params.data?.path)
? params.data.path.map((segment: unknown) => String(segment ?? '').trim()).filter(Boolean)
: []
if (path.length > 0) return path.join('/')
return '__row__'
},
// 兜底避免 AG Grid #185treeData 模式下 path 不能为空数组。
getDataPath: data => {
const path = Array.isArray(data?.path)
? data.path.map((segment: unknown) => String(segment ?? '').trim()).filter(Boolean)
: []
if (path.length > 0) return path
const fallback = String(data?.id ?? '').trim()
return [fallback || '__row__']
},
getContextMenuItems: () => ['copy', 'paste', 'separator', 'export'],
defaultColDef: {
resizable: true,

View File

@ -25,6 +25,17 @@ interface StoredFactorState {
type MaybeNumber = number | null | undefined
const sumByNumberNullable = <T>(list: T[], pick: (item: T) => MaybeNumber): number | null => {
let hasValid = false
const total = sumByNumber(list, item => {
const value = toFiniteNumberOrNull(pick(item))
if (value == null) return null
hasValid = true
return value
})
return hasValid ? total : null
}
interface ScaleRow {
id: string
amount: number | null
@ -153,6 +164,17 @@ const toStoredDetailRowsState = <TRow = unknown>(state: { detailRows?: TRow[] }
const hasOwn = (obj: unknown, key: string) =>
Object.prototype.hasOwnProperty.call(obj || {}, key)
const getRowNumberOrFallback = (
row: Record<string, unknown> | undefined,
key: string,
fallback: number | null
) => {
if (!row) return fallback
const value = toFiniteNumberOrNull(row[key])
if (value != null) return value
return hasOwn(row, key) ? null : fallback
}
const toRowMap = <TRow extends { id: string }>(rows?: TRow[]) => {
const map = new Map<string, TRow>()
for (const row of rows || []) {
@ -363,32 +385,31 @@ const getOnlyCostScaleBudgetFee = (
return /^\d+::/.test(id)
})
if (usePerRowCalculation) {
return sumByNumber(sourceRows, row => {
return sumByNumberNullable(sourceRows, row => {
const amount = toFiniteNumberOrNull(row?.amount)
if (amount == null) return null
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(amount),
majorFactor: toFiniteNumberOrNull(row?.majorFactor) ?? defaultMajorFactor,
consultCategoryFactor: toFiniteNumberOrNull(row?.consultCategoryFactor) ?? defaultConsultCategoryFactor,
workStageFactor: toFiniteNumberOrNull(row?.workStageFactor) ?? 1,
workRatio: toFiniteNumberOrNull(row?.workRatio) ?? 100
majorFactor: getRowNumberOrFallback(row, 'majorFactor', defaultMajorFactor),
consultCategoryFactor: getRowNumberOrFallback(row, 'consultCategoryFactor', defaultConsultCategoryFactor),
workStageFactor: getRowNumberOrFallback(row, 'workStageFactor', 1),
workRatio: getRowNumberOrFallback(row, 'workRatio', 100)
})
})
}
const totalAmount = sumByNumber(sourceRows, row =>
const totalAmount = sumByNumberNullable(sourceRows, row =>
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null
)
if (totalAmount == null) return null
const onlyRow =
sourceRows.find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
sourceRows.find(row => hasOwn(row, 'consultCategoryFactor') || hasOwn(row, 'majorFactor')) ||
sourceRows[0]
const consultCategoryFactor =
toFiniteNumberOrNull(onlyRow?.consultCategoryFactor) ?? defaultConsultCategoryFactor
const majorFactor =
toFiniteNumberOrNull(onlyRow?.majorFactor) ?? defaultMajorFactor
const workStageFactor = toFiniteNumberOrNull(onlyRow?.workStageFactor) ?? 1
const workRatio = toFiniteNumberOrNull(onlyRow?.workRatio) ?? 100
const consultCategoryFactor = getRowNumberOrFallback(onlyRow, 'consultCategoryFactor', defaultConsultCategoryFactor)
const majorFactor = getRowNumberOrFallback(onlyRow, 'majorFactor', defaultMajorFactor)
const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1)
const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100)
return getScaleBudgetFee({
benchmarkBudget: getBenchmarkBudgetByAmount(totalAmount),
majorFactor,
@ -405,7 +426,7 @@ const buildOnlyCostScaleDetailRows = (
majorFactorMap?: Map<string, number | null>,
industryId?: string | null
) => {
const totalAmount = sumByNumber(rowsFromDb || [], row =>
const totalAmount = sumByNumberNullable(rowsFromDb || [], row =>
typeof row?.amount === 'number' && Number.isFinite(row.amount) ? row.amount : null
)
const industryMajorEntry = getIndustryMajorEntryByIndustryId(industryId)
@ -413,17 +434,20 @@ const buildOnlyCostScaleDetailRows = (
const onlyRow =
(rowsFromDb || []).find(row => String(row?.id || '') === ONLY_COST_SCALE_ROW_ID) ||
(rowsFromDb || []).find(row => String(row?.id || '') === onlyCostRowId)
const consultCategoryFactor =
toFiniteNumberOrNull(onlyRow?.consultCategoryFactor) ??
consultCategoryFactorMap?.get(String(serviceId)) ??
getDefaultConsultCategoryFactor(serviceId)
const majorFactor =
toFiniteNumberOrNull(onlyRow?.majorFactor) ??
const consultCategoryFactor = getRowNumberOrFallback(
onlyRow,
'consultCategoryFactor',
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
)
const majorFactor = getRowNumberOrFallback(
onlyRow,
'majorFactor',
(industryMajorEntry ? majorFactorMap?.get(industryMajorEntry.id) ?? null : null) ??
toFiniteNumberOrNull(industryMajorEntry?.item?.defCoe) ??
1
const workStageFactor = toFiniteNumberOrNull(onlyRow?.workStageFactor) ?? 1
const workRatio = toFiniteNumberOrNull(onlyRow?.workRatio) ?? 100
)
const workStageFactor = getRowNumberOrFallback(onlyRow, 'workStageFactor', 1)
const workRatio = getRowNumberOrFallback(onlyRow, 'workRatio', 100)
return [
{
@ -757,7 +781,7 @@ export const getPricingMethodTotalsForService = async (params: {
consultCategoryFactorMap,
majorFactorMap
)
return sumByNumber(investRows, row => {
return sumByNumberNullable(investRows, row => {
if (!isCostMajorById(row.id)) return null
if (excludeInvestmentCostAndAreaRows && isDualScaleMajorById(row.id)) return null
return getInvestmentBudgetFee(row)
@ -771,13 +795,13 @@ export const getPricingMethodTotalsForService = async (params: {
consultCategoryFactorMap,
majorFactorMap
)
const landScale = sumByNumber(landRows, row => (isAreaMajorById(row.id) ? getLandBudgetFee(row) : null))
const landScale = sumByNumberNullable(landRows, row => (isAreaMajorById(row.id) ? getLandBudgetFee(row) : null))
const defaultWorkloadRows = buildDefaultWorkloadRows(serviceId, consultCategoryFactorMap)
const workload =
defaultWorkloadRows.length === 0
? null
: sumByNumber(
: sumByNumberNullable(
workloadData?.detailRows != null
? mergeWorkloadRows(serviceId, workloadData.detailRows as any, consultCategoryFactorMap)
: defaultWorkloadRows,
@ -788,7 +812,7 @@ export const getPricingMethodTotalsForService = async (params: {
hourlyData?.detailRows != null
? mergeHourlyRows(hourlyData.detailRows as any)
: buildDefaultHourlyRows()
const hourly = sumByNumber(hourlyRows, row => calcHourlyServiceBudget(row))
const hourly = sumByNumberNullable(hourlyRows, row => calcHourlyServiceBudget(row))
return {
investScale,

View File

@ -11,6 +11,7 @@ export interface ZxFwDetailRow {
id: string
code?: string
name?: string
process?: number | null
investScale: number | null
landScale: number | null
workload: number | null
@ -64,18 +65,31 @@ const dbKeyOf = (contractId: string) => `zxFW-${contractId}`
const serviceMethodDbKeyOf = (contractId: string, serviceId: string, method: ServicePricingMethod) =>
`${METHOD_STORAGE_PREFIX_MAP[method]}-${contractId}-${serviceId}`
const round3 = (value: number) => Number(value.toFixed(3))
const toNumberOrZero = (value: unknown) => {
const numeric = Number(value)
return Number.isFinite(numeric) ? numeric : 0
const isFiniteNumberValue = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
const sumNullableNumbers = (values: Array<number | null | undefined>): number | null => {
const validValues = values.filter(isFiniteNumberValue)
if (validValues.length === 0) return null
return addNumbers(...validValues)
}
const round3Nullable = (value: number | null | undefined) => {
const numeric = toFiniteNumberOrNull(value)
return numeric == null ? null : round3(numeric)
}
const normalizeProcessValue = (value: unknown, rowId: string) => {
if (rowId === FIXED_ROW_ID) return null
return Number(value) === 1 ? 1 : 0
}
const normalizeRows = (rows: unknown): ZxFwDetailRow[] =>
(Array.isArray(rows) ? rows : []).map(item => {
const row = item as Partial<ZxFwDetailRow>
const rowId = String(row.id || '')
return {
id: String(row.id || ''),
id: rowId,
code: typeof row.code === 'string' ? row.code : '',
name: typeof row.name === 'string' ? row.name : '',
process: normalizeProcessValue(row.process, rowId),
investScale: toFiniteNumberOrNull(row.investScale),
landScale: toFiniteNumberOrNull(row.landScale),
workload: toFiniteNumberOrNull(row.workload),
@ -88,32 +102,32 @@ const normalizeRows = (rows: unknown): ZxFwDetailRow[] =>
const applyRowSubtotals = (rows: ZxFwDetailRow[]): ZxFwDetailRow[] => {
const normalized = rows.map(row => ({ ...row }))
const nonFixedRows = normalized.filter(row => row.id !== FIXED_ROW_ID)
const totalInvestScale = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.investScale)), 0)
const totalLandScale = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.landScale)), 0)
const totalWorkload = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.workload)), 0)
const totalHourly = nonFixedRows.reduce((sum, row) => addNumbers(sum, toNumberOrZero(row.hourly)), 0)
const fixedSubtotal = addNumbers(totalInvestScale, totalLandScale, totalWorkload, totalHourly)
const totalInvestScale = sumNullableNumbers(nonFixedRows.map(row => row.investScale))
const totalLandScale = sumNullableNumbers(nonFixedRows.map(row => row.landScale))
const totalWorkload = sumNullableNumbers(nonFixedRows.map(row => row.workload))
const totalHourly = sumNullableNumbers(nonFixedRows.map(row => row.hourly))
const fixedSubtotal = sumNullableNumbers([totalInvestScale, totalLandScale, totalWorkload, totalHourly])
return normalized.map(row => {
if (row.id === FIXED_ROW_ID) {
return {
...row,
investScale: round3(totalInvestScale),
landScale: round3(totalLandScale),
workload: round3(totalWorkload),
hourly: round3(totalHourly),
subtotal: round3(fixedSubtotal)
investScale: round3Nullable(totalInvestScale),
landScale: round3Nullable(totalLandScale),
workload: round3Nullable(totalWorkload),
hourly: round3Nullable(totalHourly),
subtotal: round3Nullable(fixedSubtotal)
}
}
const subtotal = addNumbers(
toNumberOrZero(row.investScale),
toNumberOrZero(row.landScale),
toNumberOrZero(row.workload),
toNumberOrZero(row.hourly)
)
const subtotal = sumNullableNumbers([
row.investScale,
row.landScale,
row.workload,
row.hourly
])
return {
...row,
subtotal: round3(subtotal)
subtotal: round3Nullable(subtotal)
}
})
}
@ -161,6 +175,7 @@ const isSameRows = (a: ZxFwDetailRow[] | undefined, b: ZxFwDetailRow[] | undefin
if (l.id !== r.id) return false
if ((l.code || '') !== (r.code || '')) return false
if ((l.name || '') !== (r.name || '')) return false
if (normalizeProcessValue(l.process, l.id) !== normalizeProcessValue(r.process, r.id)) return false
if (!isSameNullableNumber(l.investScale, r.investScale)) return false
if (!isSameNullableNumber(l.landScale, r.landScale)) return false
if (!isSameNullableNumber(l.workload, r.workload)) return false
@ -895,12 +910,14 @@ export const useZxFwPricingStore = defineStore('zxFwPricing', () => {
const fixedSubtotal = toFiniteNumberOrNull(fixedRow?.subtotal)
if (fixedSubtotal != null) return round3(fixedSubtotal)
let hasValid = false
const sum = state.detailRows.reduce((acc, row) => {
if (String(row.id || '') === FIXED_ROW_ID) return acc
const subtotal = toFiniteNumberOrNull(row.subtotal)
if (subtotal != null) hasValid = true
return subtotal == null ? acc : acc + subtotal
}, 0)
return round3(sum)
return hasValid ? round3(sum) : null
}
const removeContractData = (contractIdRaw: string | number) => {