From aed6fe2bfa171fff4500496540b1c451843eb59d Mon Sep 17 00:00:00 2001 From: wintsa <770775984@qq.com> Date: Mon, 13 Apr 2026 11:24:47 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E6=95=B0=E6=8D=AE=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=8D=E6=AD=A3=E7=A1=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/features/ht/components/zxFw.vue | 41 +++++++++++- .../shared/components/XmFactorGrid.vue | 12 +++- src/layout/tab.vue | 36 ++++++++++- src/lib/pricingMethodTotals.ts | 64 +++++++++++++++++-- src/lib/projectWorkspace.ts | 9 +-- src/lib/reportExportBuilders.ts | 64 +++++++++++++++++-- src/lib/xmFactorDefaults.ts | 16 ++++- src/sql.ts | 2 + 8 files changed, 221 insertions(+), 23 deletions(-) diff --git a/src/features/ht/components/zxFw.vue b/src/features/ht/components/zxFw.vue index 1e2a46b..40c07b9 100644 --- a/src/features/ht/components/zxFw.vue +++ b/src/features/ht/components/zxFw.vue @@ -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() } /** diff --git a/src/features/shared/components/XmFactorGrid.vue b/src/features/shared/components/XmFactorGrid.vue index ac04e4d..cd5d9ba 100644 --- a/src/features/shared/components/XmFactorGrid.vue +++ b/src/features/shared/components/XmFactorGrid.vue @@ -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() for (const row of rowsFromDb || []) { @@ -308,7 +318,7 @@ const saveFactorChangeState = async (changedRowIds: string[]) => { const loadGridState = async (storageKey: string): Promise => { if (!storageKey) return null const piniaData = await zxFwPricingStore.loadKeyState(storageKey) - if (piniaData?.detailRows && Array.isArray(piniaData.detailRows)) return piniaData + if (hasUsablePersistedRows(piniaData)) return piniaData // 兼容历史 kvStore 数据:命中后迁移到 pinia keyed state。 const legacyData = await kvStore.getItem(storageKey) diff --git a/src/layout/tab.vue b/src/layout/tab.vue index 030e758..05c5c33 100644 --- a/src/layout/tab.vue +++ b/src/layout/tab.vue @@ -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>(storageKey), kvStore.getItem>(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 => { 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 => { }) } - 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 => { }, fee, finalFee - }) + })) const service: ExportService = { id: serviceId, @@ -2343,3 +2372,4 @@ watch( + diff --git a/src/lib/pricingMethodTotals.ts b/src/lib/pricingMethodTotals.ts index f6b4d88..1ab93f9 100644 --- a/src/lib/pricingMethodTotals.ts +++ b/src/lib/pricingMethodTotals.ts @@ -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[] = [] 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 + })) }) ) } diff --git a/src/lib/projectWorkspace.ts b/src/lib/projectWorkspace.ts index 99bf583..b3ba085 100644 --- a/src/lib/projectWorkspace.ts +++ b/src/lib/projectWorkspace.ts @@ -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)) } + diff --git a/src/lib/reportExportBuilders.ts b/src/lib/reportExportBuilders.ts index a433039..fd7d90e 100644 --- a/src/lib/reportExportBuilders.ts +++ b/src/lib/reportExportBuilders.ts @@ -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 +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) diff --git a/src/lib/xmFactorDefaults.ts b/src/lib/xmFactorDefaults.ts index 60b3369..57aac17 100644 --- a/src/lib/xmFactorDefaults.ts +++ b/src/lib/xmFactorDefaults.ts @@ -22,6 +22,15 @@ type FactorDictItem = { type FactorDict = Record +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 => { const map = new Map() 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(storageKey) : null - const data = piniaData ?? (kvStore ? await kvStore.getItem(storageKey) : null) + const kvData = kvStore ? await kvStore.getItem(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 diff --git a/src/sql.ts b/src/sql.ts index 72a3d2e..7e010b8 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -1864,6 +1864,8 @@ export function getBasicFeeFromScale( * @returns 导出流程完成后的 Promise */ export async function exportFile(fileName: string, data: any | (() => Promise), onSaveConfirmed?: () => void): Promise { + + if (window.showSaveFilePicker) { const handle = await window.showSaveFilePicker({ suggestedName: fileName,