修复数据导出不正确

This commit is contained in:
wintsa 2026-04-13 11:24:47 +08:00
parent 99684c04f2
commit aed6fe2bfa
8 changed files with 221 additions and 23 deletions

View File

@ -863,6 +863,10 @@ const getSelectedServiceIdsWithoutFixed = () =>
const ensurePricingDetailRowsForCurrentSelection = async () => {
const serviceIds = getSelectedServiceIdsWithoutFixed()
if (serviceIds.length === 0) return
console.log('[zxfw][ensure-current-selection] ' + JSON.stringify({
contractId: props.contractId,
serviceIds
}))
await ensurePricingMethodDetailRowsForServices({
contractId: props.contractId,
serviceIds,
@ -875,7 +879,6 @@ const ensurePricingDetailRowsForCurrentSelection = async () => {
* 计价法变更场景统一走这里最终会触发 applyFixedRowTotals
*/
const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
const currentState = getCurrentContractState()
const targetIds = Array.from(
new Set(
@ -893,6 +896,21 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
return
}
console.log('[zxfw][fill-pricing-totals][start] ' + JSON.stringify({
contractId: props.contractId,
requestedIds: serviceIds,
targetIds,
currentRows: currentState.detailRows.map(row => ({
id: row.id,
investScale: row.investScale,
landScale: row.landScale,
workload: row.workload,
hourly: row.hourly,
subtotal: row.subtotal,
finalFee: row.finalFee
}))
}))
await ensurePricingMethodDetailRowsForServices({
contractId: props.contractId,
serviceIds: targetIds,
@ -905,6 +923,15 @@ const fillPricingTotalsForServiceIds = async (serviceIds: string[]) => {
options: PRICING_TOTALS_OPTIONS
})
console.log('[zxfw][fill-pricing-totals][totals] ' + JSON.stringify({
contractId: props.contractId,
targetIds,
totals: targetIds.map(id => ({
serviceId: id,
totals: totalsByServiceId.get(String(id)) || null
}))
}))
const targetSet = new Set(targetIds.map(id => String(id)))
const nextRows = currentState.detailRows.map(row => {
if (isFixedRow(row) || !targetSet.has(String(row.id))) return row
@ -1014,15 +1041,23 @@ const applySelection = async (codes: string[]) => {
* 服务勾选变化入口先更新行再刷新新增服务的计价汇总
*/
const handleServiceSelectionChange = async (ids: string[]) => {
const prevIds = [...selectedIds.value]
console.log('[zxfw][selection-change][start] ' + JSON.stringify({
contractId: props.contractId,
prevIds,
nextIds: ids
}))
await applySelection(ids)
const nextSelectedIds = getCurrentContractState().selectedIds || []
const nextSelectedSet = new Set(nextSelectedIds)
const addedIds = nextSelectedIds.filter(id => !prevIds.includes(id) && nextSelectedSet.has(id))
console.log('[zxfw][selection-change][after-apply] ' + JSON.stringify({
contractId: props.contractId,
nextSelectedIds,
addedIds
}))
await ensureWorkContentStateForServices(addedIds)
await fillPricingTotalsForServiceIds(addedIds)
await ensurePricingDetailRowsForCurrentSelection()
}
/**

View File

@ -138,6 +138,16 @@ const hasMeaningfulFactorValue = (rows: SourceRow[] | undefined) =>
return hasBudgetValue || hasRemark
})
const hasUsablePersistedRows = (state: GridState | null | undefined) =>
Array.isArray(state?.detailRows) &&
state.detailRows.some(row => {
const hasFactor =
typeof row?.budgetValue === 'number' ||
typeof row?.standardFactor === 'number'
const hasRemark = typeof row?.remark === 'string' && row.remark.trim() !== ''
return hasFactor || hasRemark || String(row?.id || '').trim() !== ''
})
const mergeWithDictRows = (rowsFromDb: SourceRow[] | undefined): FactorRow[] => {
const dbValueMap = new Map<string, SourceRow>()
for (const row of rowsFromDb || []) {
@ -308,7 +318,7 @@ const saveFactorChangeState = async (changedRowIds: string[]) => {
const loadGridState = async (storageKey: string): Promise<GridState | null> => {
if (!storageKey) return null
const piniaData = await zxFwPricingStore.loadKeyState<GridState>(storageKey)
if (piniaData?.detailRows && Array.isArray(piniaData.detailRows)) return piniaData
if (hasUsablePersistedRows(piniaData)) return piniaData
// kvStore pinia keyed state
const legacyData = await kvStore.getItem<GridState>(storageKey)

View File

@ -987,15 +987,31 @@ const getPiniaPersistStores = () =>
}
})
const hasExportableFactorRows = (rows: FactorRowLike[] | undefined) =>
Array.isArray(rows) &&
rows.some(row => {
const rowId = toSafeInteger(row?.id)
const coe = toFiniteNumber(row?.budgetValue) ?? toFiniteNumber(row?.standardFactor)
const remark = typeof row?.remark === 'string' ? row.remark.trim() : ''
return rowId != null || coe != null || remark !== ''
})
const loadFactorRowsState = async (storageKey: string) => {
const [piniaData, kvData] = await Promise.all([
zxFwPricingStore.loadKeyState<DetailRowsStorageLike<FactorRowLike>>(storageKey),
kvStore.getItem<DetailRowsStorageLike<FactorRowLike>>(storageKey)
])
const piniaRows = Array.isArray(piniaData?.detailRows) ? piniaData.detailRows : undefined
const kvRows = Array.isArray(kvData?.detailRows) ? kvData.detailRows : undefined
const resolved = hasExportableFactorRows(piniaRows)
? piniaData
: hasExportableFactorRows(kvRows)
? kvData
: (piniaData ?? kvData ?? null)
return {
piniaData,
kvData,
resolved: piniaData || kvData || null
resolved
}
}
@ -1197,6 +1213,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
const projectServiceCoes = buildProjectServiceCoes(resolveExportFactorRows(consultCategoryFactorState))
const projectMajorCoes = buildProjectMajorCoes(resolveExportFactorRows(majorFactorState))
const projectName = isNonEmptyString(projectInfo.projectName)
? projectInfo.projectName.trim()
: t('tab.messages.defaultProjectName')
@ -1352,10 +1369,22 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
})
}
console.log('[export][service-methods]', {
console.log('[export][service-methods] ' + JSON.stringify({
contractId,
serviceId: serviceIdText,
methodAvailability,
rawLengths: {
method1: Array.isArray(method1Raw?.detailRows) ? method1Raw.detailRows.length : -1,
method2: Array.isArray(method2Raw?.detailRows) ? method2Raw.detailRows.length : -1,
method3: Array.isArray(method3Raw?.detailRows) ? method3Raw.detailRows.length : -1,
method4: Array.isArray(method4Raw?.detailRows) ? method4Raw.detailRows.length : -1
},
rawSamples: {
method1: Array.isArray(method1Raw?.detailRows) ? method1Raw.detailRows[0] : null,
method2: Array.isArray(method2Raw?.detailRows) ? method2Raw.detailRows[0] : null,
method3: Array.isArray(method3Raw?.detailRows) ? method3Raw.detailRows[0] : null,
method4: Array.isArray(method4Raw?.detailRows) ? method4Raw.detailRows[0] : null
},
exported: {
method1: Boolean(method1),
method2: Boolean(method2),
@ -1364,7 +1393,7 @@ const buildExportReportPayload = async (): Promise<ExportReportPayload> => {
},
fee,
finalFee
})
}))
const service: ExportService = {
id: serviceId,
@ -2343,3 +2372,4 @@ watch(
</template>
<style scoped src="@/features/tab/tab.css"></style>

View File

@ -50,6 +50,8 @@ const getOnlyCostScaleSummaryAmount = (
interface ScaleRow {
id: string
hasCost?: boolean
hasArea?: boolean
amount: number | null
landArea: number | null
benchmarkBudgetBasicChecked: boolean
@ -327,6 +329,8 @@ const buildDefaultScaleRows = (
consultCategoryFactorMap?.get(String(serviceId)) ?? getDefaultConsultCategoryFactor(serviceId)
return getMajorLeafIds().map(id => ({
id,
hasCost: isCostMajorById(id),
hasArea: isAreaMajorById(id),
amount: null,
landArea: null,
benchmarkBudgetBasicChecked: true,
@ -367,6 +371,14 @@ const mergeScaleRows = (
return {
...row,
hasCost:
typeof (fromDb as { hasCost?: unknown }).hasCost === 'boolean'
? Boolean((fromDb as { hasCost?: unknown }).hasCost)
: row.hasCost,
hasArea:
typeof (fromDb as { hasArea?: unknown }).hasArea === 'boolean'
? Boolean((fromDb as { hasArea?: unknown }).hasArea)
: row.hasArea,
amount: toFiniteNumberOrNull(fromDb.amount),
landArea: toFiniteNumberOrNull(fromDb.landArea),
benchmarkBudgetBasicChecked:
@ -490,6 +502,8 @@ const buildInvestScaleSingleTotalDetailRows = (
return [
{
id: onlyCostRowId,
hasCost: true,
hasArea: false,
amount: resolvedTotalAmount,
landArea: null,
consultCategoryFactor,
@ -657,6 +671,8 @@ const normalizeScopedScaleRows = (
const hasWorkRatio = hasOwn(row, 'workRatio')
return {
id: resolvedMajorId,
hasCost: isCostMajorById(resolvedMajorId),
hasArea: isAreaMajorById(resolvedMajorId),
amount: toFiniteNumberOrNull(row.amount),
landArea: toFiniteNumberOrNull(row.landArea),
benchmarkBudgetBasicChecked:
@ -944,16 +960,49 @@ export const ensurePricingMethodDetailRowsForServices = async (params: {
const workloadData = toStoredDetailRowsState(storeWorkloadData) || workloadDataFallback
const hourlyData = toStoredDetailRowsState(storeHourlyData) || hourlyDataFallback
const shouldInitInvest = !Array.isArray(investData?.detailRows) || investData!.detailRows!.length === 0
const shouldInitLand = !Array.isArray(landData?.detailRows) || landData!.detailRows!.length === 0
const shouldInitWorkload = !Array.isArray(workloadData?.detailRows) || workloadData!.detailRows!.length === 0
const shouldInitHourly = !Array.isArray(hourlyData?.detailRows) || hourlyData!.detailRows!.length === 0
const shouldInitInvest = !Array.isArray(investData?.detailRows)
const shouldInitLand = !Array.isArray(landData?.detailRows)
const shouldInitWorkload = !Array.isArray(workloadData?.detailRows)
const shouldInitHourly = !Array.isArray(hourlyData?.detailRows)
console.log('[pricing][ensure-detail-rows][before] ' + JSON.stringify({
contractId: params.contractId,
serviceId,
shouldInit: {
invest: shouldInitInvest,
land: shouldInitLand,
workload: shouldInitWorkload,
hourly: shouldInitHourly
},
existingLengths: {
invest: Array.isArray(investData?.detailRows) ? investData.detailRows.length : -1,
land: Array.isArray(landData?.detailRows) ? landData.detailRows.length : -1,
workload: Array.isArray(workloadData?.detailRows) ? workloadData.detailRows.length : -1,
hourly: Array.isArray(hourlyData?.detailRows) ? hourlyData.detailRows.length : -1
}
}))
const writeTasks: Promise<unknown>[] = []
let defaultRows: PricingMethodDefaultDetailRows | null = null
const getDefaultRows = () => {
if (!defaultRows) {
defaultRows = buildDefaultPricingMethodDetailRows(serviceId, context)
console.log('[pricing][ensure-detail-rows][defaults] ' + JSON.stringify({
contractId: params.contractId,
serviceId,
lengths: {
invest: Array.isArray(defaultRows.investScale) ? defaultRows.investScale.length : -1,
land: Array.isArray(defaultRows.landScale) ? defaultRows.landScale.length : -1,
workload: Array.isArray(defaultRows.workload) ? defaultRows.workload.length : -1,
hourly: Array.isArray(defaultRows.hourly) ? defaultRows.hourly.length : -1
},
sample: {
invest: Array.isArray(defaultRows.investScale) ? defaultRows.investScale[0] : null,
land: Array.isArray(defaultRows.landScale) ? defaultRows.landScale[0] : null,
workload: Array.isArray(defaultRows.workload) ? defaultRows.workload[0] : null,
hourly: Array.isArray(defaultRows.hourly) ? defaultRows.hourly[0] : null
}
}))
}
return defaultRows
}
@ -997,6 +1046,13 @@ export const ensurePricingMethodDetailRowsForServices = async (params: {
if (writeTasks.length > 0) {
await Promise.all(writeTasks)
}
console.log('[pricing][ensure-detail-rows][after] ' + JSON.stringify({
contractId: params.contractId,
serviceId,
wroteAny: writeTasks.length > 0,
writeCount: writeTasks.length
}))
})
)
}

View File

@ -294,10 +294,10 @@ export const initializeProjectFactorStates = async (
detailRows: buildFactorRowsFromEntries(majorEntries)
}
await Promise.all([
kvStore.setItem(consultCategoryFactorKey, consultPayload),
kvStore.setItem(majorFactorKey, majorPayload)
])
// 新项目初始化走 createProjectKvAdapter 时setItem 是整包读改写,不是原子更新。
// 这里并发写两个 key 会互相覆盖,导致咨询系数或专业系数其中一个丢失。
await kvStore.setItem(consultCategoryFactorKey, consultPayload)
await kvStore.setItem(majorFactorKey, majorPayload)
}
export const initializeProjectScaleState = async (
@ -307,3 +307,4 @@ export const initializeProjectScaleState = async (
) => {
await kvStore.setItem(projectScaleKey, buildDefaultProjectScaleState(industry))
}

View File

@ -1,4 +1,4 @@
import { serviceList } from '@/sql'
import { getMajorDictById, getMajorIdAliasMap, serviceList } from '@/sql'
import { roundTo, toFiniteNumber, toFiniteNumberOrZero } from '@/lib/decimal'
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
export { toFiniteNumber, toFiniteNumberOrZero }
@ -52,6 +52,7 @@ interface ScaleRowLike {
interface WorkloadMethodRowLike {
id: string
conversion?: unknown
budgetAdoptedUnitPrice?: unknown
workload?: unknown
basicFee?: unknown
@ -246,6 +247,18 @@ export const toScaleMajorId = (row: ScaleMethodRowLike): number | null => {
return toSafeInteger(parsed.majorPart)
}
const majorDictById = getMajorDictById() as Record<string, { hasCost?: unknown; hasArea?: unknown } | undefined>
const majorIdAliasMap = getMajorIdAliasMap()
const resolveMajorCapability = (majorId: number | null) => {
if (majorId == null) return null
const key = String(majorId)
const resolvedKey = Object.prototype.hasOwnProperty.call(majorDictById, key)
? key
: (majorIdAliasMap.get(key) || key)
return majorDictById[resolvedKey] || null
}
export const toScaleProNum = (row: ScaleMethodRowLike): number => {
const parsed = parseScaleScopedRowId(row.id)
return parsed.proNum > 0 ? parsed.proNum : 1
@ -276,7 +289,16 @@ const isExportableScaleMethodRow = (
mode: 'cost' | 'area'
) => {
if (!isScaleLeafRow(row)) return false
return mode === 'cost' ? row?.hasCost === true : row?.hasArea === true
if (mode === 'cost') {
if (row?.hasCost === true) return true
if (row?.hasCost === false) return false
} else {
if (row?.hasArea === true) return true
if (row?.hasArea === false) return false
}
const major = row ? resolveMajorCapability(toScaleMajorId(row)) : null
if (!major) return false
return mode === 'cost' ? major.hasCost !== false : major.hasArea !== false
}
export const normalizeTaskText = (value: unknown): string => String(value || '').trim()
@ -527,16 +549,34 @@ export const buildMethod2 = (rows: ScaleMethodRowLike[] | undefined) => {
}
}
const resolveWorkloadBasicFee = (row: WorkloadMethodRowLike) => {
const basicFee = toFiniteNumber(row.basicFee)
if (basicFee != null) return basicFee
const price = toFiniteNumber(row.budgetAdoptedUnitPrice)
const conversion = toFiniteNumber(row.conversion)
const amount = toFiniteNumber(row.workload)
if (price == null || conversion == null || amount == null) return null
return roundTo(price * conversion * amount, 2)
}
const resolveWorkloadServiceFee = (row: WorkloadMethodRowLike, basicFee: number | null) => {
const fee = toFiniteNumber(row.serviceFee)
if (fee != null) return fee
const factor = toFiniteNumber(row.consultCategoryFactor)
if (basicFee == null || factor == null) return null
return roundTo(basicFee * factor, 2)
}
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
if (task == null) return null
const amount = toFiniteNumber(row.workload)
const basicFee = toFiniteNumber(row.basicFee)
const fee = toFiniteNumber(row.serviceFee)
const basicFee = resolveWorkloadBasicFee(row)
const fee = resolveWorkloadServiceFee(row, basicFee)
if (fee != null) hasTotalValue = true
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue = amount != null || basicFee != null || fee != null || isNonEmptyString(remark)
@ -561,16 +601,26 @@ export const buildMethod3 = (rows: WorkloadMethodRowLike[] | undefined) => {
}
}
const resolveHourlyServiceFee = (row: HourlyMethodRowLike) => {
const fee = toFiniteNumber(row.serviceBudget)
if (fee != null) return fee
const price = toFiniteNumber(row.adoptedBudgetUnitPrice)
const personNum = toFiniteNumber(row.personnelCount)
const workDay = toFiniteNumber(row.workdayCount)
if (price == null || personNum == null || workDay == null) return null
return roundTo(price * personNum * workDay, 2)
}
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
if (expert == null) return null
const personNum = toFiniteNumber(row.personnelCount)
const workDay = toFiniteNumber(row.workdayCount)
const fee = toFiniteNumber(row.serviceBudget)
const fee = resolveHourlyServiceFee(row)
if (fee != null) hasTotalValue = true
const remark = typeof row.remark === 'string' ? row.remark : ''
const hasValue = personNum != null || workDay != null || fee != null || isNonEmptyString(remark)

View File

@ -22,6 +22,15 @@ type FactorDictItem = {
type FactorDict = Record<string, FactorDictItem>
const hasUsableFactorRows = (state: XmFactorState | null | undefined) =>
Array.isArray(state?.detailRows) &&
state.detailRows.some(row => {
const hasFactor =
toFiniteNumberOrNull(row?.budgetValue) != null ||
toFiniteNumberOrNull(row?.standardFactor) != null
return hasFactor || String(row?.id || '').trim() !== ''
})
const buildStandardFactorMap = (dict: FactorDict): Map<string, number | null> => {
const map = new Map<string, number | null>()
for (const [id, item] of Object.entries(dict)) {
@ -68,7 +77,12 @@ const loadFactorMap = async (
const zxFwPricingStore = getZxFwPricingStoreSafely()
const kvStore = getKvStoreSafely()
const piniaData = zxFwPricingStore ? await zxFwPricingStore.loadKeyState<XmFactorState>(storageKey) : null
const data = piniaData ?? (kvStore ? await kvStore.getItem<XmFactorState>(storageKey) : null)
const kvData = kvStore ? await kvStore.getItem<XmFactorState>(storageKey) : null
const data = hasUsableFactorRows(piniaData)
? piniaData
: hasUsableFactorRows(kvData)
? kvData
: (piniaData ?? kvData)
const map = buildStandardFactorMap(dict)
for (const row of data?.detailRows || []) {
if (!row?.id) continue

View File

@ -1864,6 +1864,8 @@ export function getBasicFeeFromScale(
* @returns Promise
*/
export async function exportFile(fileName: string, data: any | (() => Promise<any>), onSaveConfirmed?: () => void): Promise<string | null> {
if (window.showSaveFilePicker) {
const handle = await window.showSaveFilePicker({
suggestedName: fileName,