import { roundTo, sumByNumber } from '@/lib/decimal' import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals' import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee' import { useKvStore } from '@/pinia/kv' import { getMajorDictEntries, getServiceDictItemById } from '@/sql' import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing' export type { ZxFwPricingField } from '@/pinia/zxFwPricing' const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const const PROJECT_ROW_ID_SEPARATOR = '::' const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' const majorIdAliasMap = new Map(getMajorDictEntries().map(({ rawId, id }) => [rawId, id])) type ServiceLite = { mutiple?: boolean | null onlyCostScale?: boolean | null } type ScaleDetailRow = { id: string projectIndex?: number majorDictId?: string amount?: number | null landArea?: number | null benchmarkBudget?: number | null benchmarkBudgetBasic?: number | null benchmarkBudgetOptional?: number | null benchmarkBudgetBasicChecked?: boolean benchmarkBudgetOptionalChecked?: boolean basicFormula?: string | null optionalFormula?: string | null consultCategoryFactor?: number | null majorFactor?: number | null workStageFactor?: number | null workRatio?: number | null budgetFee?: number | null budgetFeeBasic?: number | null budgetFeeOptional?: number | null path?: string[] } const normalizeProjectCount = (value: unknown) => { const parsed = Number(value) if (!Number.isFinite(parsed)) return 1 return Math.max(1, Math.floor(parsed)) } const parseProjectIndexFromPathKey = (value: string) => { const match = /^project-(\d+)$/.exec(value) if (!match) return null return normalizeProjectCount(Number(match[1])) } const parseScopedRowId = (id: unknown) => { const rawId = String(id || '') const match = /^(\d+)::(.+)$/.exec(rawId) if (!match) { return { projectIndex: 1, majorDictId: rawId } } return { projectIndex: normalizeProjectCount(Number(match[1])), majorDictId: String(match[2] || '').trim() } } const resolveRowProjectIndex = (row: Partial | undefined) => { if (!row) return 1 if (typeof row.projectIndex === 'number' && Number.isFinite(row.projectIndex)) { return normalizeProjectCount(row.projectIndex) } if (Array.isArray(row.path) && row.path.length > 0) { const projectIndexFromPath = parseProjectIndexFromPathKey(String(row.path[0] || '')) if (projectIndexFromPath != null) return projectIndexFromPath } return parseScopedRowId(row.id).projectIndex } const resolveRowMajorDictId = (row: Partial | undefined) => { if (!row) return '' const direct = String(row.majorDictId || '').trim() if (direct) return majorIdAliasMap.get(direct) || direct const parsed = parseScopedRowId(row.id).majorDictId return majorIdAliasMap.get(parsed) || parsed } const makeProjectMajorKey = (projectIndex: number, majorDictId: string) => `${normalizeProjectCount(projectIndex)}:${String(majorDictId || '').trim()}` const buildContractScaleMap = (rows: ScaleDetailRow[] | undefined) => { const map = new Map() for (const row of rows || []) { const majorDictId = resolveRowMajorDictId(row) if (!majorDictId) continue const projectIndex = resolveRowProjectIndex(row) map.set(makeProjectMajorKey(projectIndex, majorDictId), row) } return map } const getContractScaleRowByMajor = (row: ScaleDetailRow, map: Map) => { const majorDictId = resolveRowMajorDictId(row) if (!majorDictId) return undefined const projectIndex = resolveRowProjectIndex(row) return map.get(makeProjectMajorKey(projectIndex, majorDictId)) || (projectIndex > 1 ? map.get(makeProjectMajorKey(1, majorDictId)) : undefined) } const calcOnlyCostScaleAmountFromRows = (rows?: Array<{ amount?: unknown }>) => sumByNumber(rows || [], row => (typeof row?.amount === 'number' ? row.amount : null)) const isSameNullableNumber = (left: number | null | undefined, right: number | null | undefined) => { if (left == null && right == null) return true if (left == null || right == null) return false return roundTo(left, 6) === roundTo(right, 6) } const recomputeScaleRow = ( row: ScaleDetailRow, mode: 'cost' | 'area' ): ScaleDetailRow => { const scaleValue = mode === 'cost' ? row.amount : row.landArea const rawSplit = getBenchmarkBudgetSplitByScale(scaleValue, mode) const checkedSplit = rawSplit ? { basic: row.benchmarkBudgetBasicChecked === false ? 0 : rawSplit.basic, optional: row.benchmarkBudgetOptionalChecked === false ? 0 : rawSplit.optional, total: (row.benchmarkBudgetBasicChecked === false ? 0 : rawSplit.basic) + (row.benchmarkBudgetOptionalChecked === false ? 0 : rawSplit.optional) } : null const budgetFeeSplit = checkedSplit ? getScaleBudgetFeeSplit({ benchmarkBudgetBasic: checkedSplit.basic, benchmarkBudgetOptional: checkedSplit.optional, majorFactor: row.majorFactor, consultCategoryFactor: row.consultCategoryFactor, workStageFactor: row.workStageFactor, workRatio: row.workRatio }) : null return { ...row, benchmarkBudget: checkedSplit ? roundTo(checkedSplit.total, 2) : null, benchmarkBudgetBasic: checkedSplit ? roundTo(checkedSplit.basic, 2) : null, benchmarkBudgetOptional: checkedSplit ? roundTo(checkedSplit.optional, 2) : null, basicFormula: row.benchmarkBudgetBasicChecked === false ? null : (rawSplit?.basicFormula ?? ''), optionalFormula: row.benchmarkBudgetOptionalChecked === false ? null : (rawSplit?.optionalFormula ?? ''), budgetFee: budgetFeeSplit?.total ?? null, budgetFeeBasic: budgetFeeSplit?.basic ?? null, budgetFeeOptional: budgetFeeSplit?.optional ?? null } } const getScaleMethodTotalBudgetFee = (rows: ScaleDetailRow[]) => { const hasValue = rows.some(row => typeof row.budgetFee === 'number' && Number.isFinite(row.budgetFee)) if (!hasValue) return null return roundTo(sumByNumber(rows, row => row.budgetFee ?? null), 2) } const syncScaleMethodRows = async (params: { contractId: string serviceId: string method: ServicePricingMethod sourceRowMap: Map onlyCostScaleFallbackAmount?: number | null isOnlyCostScaleService?: boolean }) => { const store = useZxFwPricingStore() const methodState = await store.loadServicePricingMethodState( params.contractId, params.serviceId, params.method ) if (!methodState?.detailRows?.length) return let changed = false const nextRows = methodState.detailRows.map(rawRow => { const row = { ...rawRow } if (params.method === 'investScale') { const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap) const nextAmount = params.isOnlyCostScaleService ? ( typeof sourceRow?.amount === 'number' ? sourceRow.amount : (params.onlyCostScaleFallbackAmount ?? null) ) : ( typeof sourceRow?.amount === 'number' ? sourceRow.amount : null ) if (!isSameNullableNumber(row.amount, nextAmount)) { row.amount = nextAmount changed = true } const recomputed = recomputeScaleRow(row, 'cost') if (JSON.stringify(recomputed) !== JSON.stringify(rawRow)) { changed = true } return recomputed } const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap) const nextLandArea = typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null if (!isSameNullableNumber(row.landArea, nextLandArea)) { row.landArea = nextLandArea changed = true } const recomputed = recomputeScaleRow(row, 'area') if (JSON.stringify(recomputed) !== JSON.stringify(rawRow)) { changed = true } return recomputed }) if (!changed) return store.setServicePricingMethodState( params.contractId, params.serviceId, params.method, { detailRows: nextRows, projectCount: methodState.projectCount ?? null }, { force: true } ) await syncPricingTotalToZxFw({ contractId: params.contractId, serviceId: params.serviceId, field: params.method, value: getScaleMethodTotalBudgetFee(nextRows) }) } export const syncContractScaleToPricing = async (contractId: string) => { const store = useZxFwPricingStore() const kvStore = useKvStore() await store.loadContract(contractId) const currentState = store.getContractState(contractId) const selectedIds = Array.from(new Set((currentState?.selectedIds || []).map(id => String(id || '').trim()).filter(Boolean))) if (selectedIds.length === 0) return await ensurePricingMethodDetailRowsForServices({ contractId, serviceIds: selectedIds, options: PRICING_TOTALS_OPTIONS }) const htData = await kvStore.getItem<{ detailRows?: ScaleDetailRow[] }>(`ht-info-v3-${contractId}`) const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : [] const sourceRowMap = buildContractScaleMap(sourceRows) const onlyCostScaleFallbackAmount = calcOnlyCostScaleAmountFromRows(sourceRows) for (const serviceId of selectedIds) { const service = getServiceDictItemById(serviceId) as ServiceLite | undefined await syncScaleMethodRows({ contractId, serviceId, method: 'investScale', sourceRowMap, onlyCostScaleFallbackAmount, isOnlyCostScaleService: service?.onlyCostScale === true }) await syncScaleMethodRows({ contractId, serviceId, method: 'landScale', sourceRowMap }) } } export const syncPricingTotalToZxFw = async (params: { contractId: string serviceId: string | number field: ZxFwPricingField value: number | null | undefined }) => { const store = useZxFwPricingStore() return store.updatePricingField(params) }