import { roundTo, sumByNumber, toDecimal } from '@/lib/decimal' import { ensurePricingMethodDetailRowsForServices } from '@/lib/pricingMethodTotals' import { getServiceDictItemById } from '@/sql' import { isSameNullableNumber, isSameScaleDetailRow, recomputeScaleDetailRow } from '@/lib/pricingScaleDetail' import { buildContractScaleIdMap, buildContractScaleMap, buildContractScaleProjectTotals, getContractScaleRowByMajor, getContractScaleProjectTotalsByRow, normalizeChangedScaleRowIds, resolveScaleRowMajorDictId as resolveRowMajorDictId } from '@/lib/pricingScaleLink' import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults' import { useKvStore } from '@/pinia/kv' import { useZxFwPricingStore, type ServicePricingMethod, type ZxFwPricingField } from '@/pinia/zxFwPricing' export type { ZxFwPricingField } from '@/pinia/zxFwPricing' const PRICING_TOTALS_OPTIONS = { excludeInvestmentCostAndAreaRows: true } as const const ONLY_COST_SCALE_ROW_ID = '__only-cost-scale-total__' 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 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) } type WorkloadDetailRow = { id: string conversion?: number | null workload?: number | null budgetAdoptedUnitPrice?: number | null consultCategoryFactor?: number | null basicFee?: number | null serviceFee?: number | null } type ServiceLite = { onlyCostScale?: boolean | null } const normalizeServiceIdSet = (serviceIds?: Array) => new Set((serviceIds || []).map(id => String(id || '').trim()).filter(Boolean)) const calcWorkloadBasicFee = (row: WorkloadDetailRow) => { if (String(row.id || '').startsWith('task-none-')) return null const price = row.budgetAdoptedUnitPrice const conversion = row.conversion const workload = row.workload if ( typeof price !== 'number' || !Number.isFinite(price) || typeof conversion !== 'number' || !Number.isFinite(conversion) || typeof workload !== 'number' || !Number.isFinite(workload) ) { return null } return roundTo(toDecimal(price).mul(conversion).mul(workload), 2) } const calcWorkloadServiceFee = (row: WorkloadDetailRow) => { if (String(row.id || '').startsWith('task-none-')) return null const factor = row.consultCategoryFactor const basicFee = calcWorkloadBasicFee(row) if ( basicFee == null || typeof factor !== 'number' || !Number.isFinite(factor) ) { return null } return roundTo(toDecimal(basicFee).mul(factor), 2) } const getWorkloadMethodTotalServiceFee = (rows: WorkloadDetailRow[]) => { const hasValue = rows.some(row => typeof row.serviceFee === 'number' && Number.isFinite(row.serviceFee)) if (!hasValue) return null return roundTo(sumByNumber(rows, row => row.serviceFee ?? null), 2) } const isOnlyCostScaleService = (serviceId: string) => { const service = getServiceDictItemById(serviceId) as ServiceLite | undefined return service?.onlyCostScale === true } const matchesChangedScaleRow = ( row: ScaleDetailRow, changedRowIds?: Set, options?: { bypassFilter?: boolean } ) => { if (options?.bypassFilter) return true if (!changedRowIds || changedRowIds.size === 0) return true const majorDictId = resolveRowMajorDictId(row) return Boolean(majorDictId && changedRowIds.has(majorDictId)) } const syncScaleMethodRows = async (params: { contractId: string serviceId: string method: ServicePricingMethod sourceRowMap: Map sourceRowIdMap: Map projectTotals: Map changedRowIds?: Set }) => { const store = useZxFwPricingStore() const methodState = await store.loadServicePricingMethodState( params.contractId, params.serviceId, params.method ) if (!methodState?.detailRows?.length) return 0 let changed = false let changedRowCount = 0 const useSummaryScaleValues = methodState.detailRows.length === 1 || (params.method === 'investScale' && isOnlyCostScaleService(params.serviceId)) const nextRows = methodState.detailRows.map(rawRow => { const mode = params.method === 'investScale' ? 'cost' : 'area' if (!matchesChangedScaleRow(rawRow, params.changedRowIds, { bypassFilter: useSummaryScaleValues })) return rawRow const row = { ...rawRow } const sourceRow = getContractScaleRowByMajor(row, params.sourceRowMap, params.sourceRowIdMap) const projectTotals = getContractScaleProjectTotalsByRow(row, params.projectTotals) if (mode === 'cost') { const nextAmount = useSummaryScaleValues ? projectTotals.amount : (typeof sourceRow?.amount === 'number' ? sourceRow.amount : null) row.amount = isSameNullableNumber(row.amount, nextAmount) ? row.amount : nextAmount } else { const nextLandArea = useSummaryScaleValues ? projectTotals.landArea : (typeof sourceRow?.landArea === 'number' ? sourceRow.landArea : null) row.landArea = isSameNullableNumber(row.landArea, nextLandArea) ? row.landArea : nextLandArea } const recomputed = recomputeScaleDetailRow(row, mode) if (isSameScaleDetailRow(rawRow, recomputed, mode)) return rawRow changed = true changedRowCount += 1 return recomputed }) if (!changed) return 0 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) }) return changedRowCount } export interface ContractScaleSyncResult { updatedServiceCount: number updatedMethodCount: number updatedRowCount: number } export const syncContractScaleToPricing = async ( contractId: string, options?: { changedRowIds?: string[] } ): Promise => { 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))) const changedRowIdSet = options?.changedRowIds?.length ? normalizeChangedScaleRowIds(options.changedRowIds) : undefined if (selectedIds.length === 0) { return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 } } if (changedRowIdSet && changedRowIdSet.size === 0) { return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 } } await ensurePricingMethodDetailRowsForServices({ contractId, serviceIds: selectedIds, options: PRICING_TOTALS_OPTIONS }) const htData = await kvStore.getItem<{ detailRows?: ScaleDetailRow[]; totalAmount?: number | null }>(`ht-info-v3-${contractId}`) const sourceRows = Array.isArray(htData?.detailRows) ? htData.detailRows : [] const sourceRowMap = buildContractScaleMap(sourceRows) const sourceRowIdMap = buildContractScaleIdMap(sourceRows) const projectTotals = buildContractScaleProjectTotals(sourceRows, htData?.totalAmount) const updatedServiceIdSet = new Set() let updatedMethodCount = 0 let updatedRowCount = 0 for (const serviceId of selectedIds) { const investChangedCount = await syncScaleMethodRows({ contractId, serviceId, method: 'investScale', sourceRowMap, sourceRowIdMap, projectTotals, changedRowIds: changedRowIdSet }) if (investChangedCount > 0) { updatedServiceIdSet.add(serviceId) updatedMethodCount += 1 updatedRowCount += investChangedCount } const landChangedCount = await syncScaleMethodRows({ contractId, serviceId, method: 'landScale', sourceRowMap, sourceRowIdMap, projectTotals, changedRowIds: changedRowIdSet }) if (landChangedCount > 0) { updatedServiceIdSet.add(serviceId) updatedMethodCount += 1 updatedRowCount += landChangedCount } } return { updatedServiceCount: updatedServiceIdSet.size, updatedMethodCount, updatedRowCount } } const syncScaleMethodFactors = async (params: { contractId: string serviceId: string method: 'investScale' | 'landScale' syncConsultFactor: boolean consultFactor: number | null majorChangedRowIds?: Set majorFactorMap: Map }) => { const store = useZxFwPricingStore() const methodState = await store.loadServicePricingMethodState( params.contractId, params.serviceId, params.method ) if (!methodState?.detailRows?.length) return 0 let changed = false let changedRowCount = 0 const mode = params.method === 'investScale' ? 'cost' : 'area' const nextRows = methodState.detailRows.map(rawRow => { const row = { ...rawRow } let rowChanged = false if (params.syncConsultFactor && !isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) { row.consultCategoryFactor = params.consultFactor rowChanged = true } if (params.majorChangedRowIds?.size) { const majorDictId = resolveRowMajorDictId(row) if (params.majorChangedRowIds.has(majorDictId)) { const nextMajorFactor = params.majorFactorMap.get(majorDictId) ?? null if (!isSameNullableNumber(row.majorFactor, nextMajorFactor)) { row.majorFactor = nextMajorFactor rowChanged = true } } } if (!rowChanged) return rawRow const recomputed = recomputeScaleDetailRow(row, mode) if (isSameScaleDetailRow(rawRow, recomputed, mode)) return rawRow changed = true changedRowCount += 1 return recomputed }) if (!changed) return 0 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) }) return changedRowCount } const syncWorkloadMethodConsultFactor = async (params: { contractId: string serviceId: string consultFactor: number | null }) => { const store = useZxFwPricingStore() const methodState = await store.loadServicePricingMethodState( params.contractId, params.serviceId, 'workload' ) if (!methodState?.detailRows?.length) return 0 let changed = false let changedRowCount = 0 const nextRows = methodState.detailRows.map(rawRow => { const row = { ...rawRow } let rowChanged = false if (!isSameNullableNumber(row.consultCategoryFactor, params.consultFactor)) { row.consultCategoryFactor = params.consultFactor rowChanged = true } const nextBasicFee = calcWorkloadBasicFee(row) const nextServiceFee = calcWorkloadServiceFee(row) if (!isSameNullableNumber(row.basicFee, nextBasicFee)) { row.basicFee = nextBasicFee rowChanged = true } if (!isSameNullableNumber(row.serviceFee, nextServiceFee)) { row.serviceFee = nextServiceFee rowChanged = true } if (!rowChanged) return rawRow changed = true changedRowCount += 1 return row }) if (!changed) return 0 store.setServicePricingMethodState( params.contractId, params.serviceId, 'workload', { detailRows: nextRows, projectCount: methodState.projectCount ?? null }, { force: true } ) await syncPricingTotalToZxFw({ contractId: params.contractId, serviceId: params.serviceId, field: 'workload', value: getWorkloadMethodTotalServiceFee(nextRows) }) return changedRowCount } export interface ContractFactorSyncResult { updatedServiceCount: number updatedMethodCount: number updatedRowCount: number } export const syncContractFactorsToPricing = async ( contractId: string, options?: { consultChangedServiceIds?: string[] majorChangedRowIds?: string[] } ): Promise => { const store = useZxFwPricingStore() 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 { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 } } const consultChangedServiceIdSet = normalizeServiceIdSet(options?.consultChangedServiceIds) const majorChangedRowIdSet = normalizeChangedScaleRowIds(options?.majorChangedRowIds) if (consultChangedServiceIdSet.size === 0 && majorChangedRowIdSet.size === 0) { return { updatedServiceCount: 0, updatedMethodCount: 0, updatedRowCount: 0 } } const [consultFactorMap, majorFactorMap] = await Promise.all([ loadConsultCategoryFactorMap(`ht-consult-category-factor-v1-${contractId}`), loadMajorFactorMap(`ht-major-factor-v1-${contractId}`) ]) let updatedMethodCount = 0 let updatedRowCount = 0 const updatedServiceIdSet = new Set() for (const serviceId of selectedIds) { const syncConsultFactor = consultChangedServiceIdSet.has(serviceId) const syncMajorFactor = majorChangedRowIdSet.size > 0 if (!syncConsultFactor && !syncMajorFactor) continue const consultFactor = consultFactorMap.get(serviceId) ?? null const investChangedCount = await syncScaleMethodFactors({ contractId, serviceId, method: 'investScale', syncConsultFactor, consultFactor, majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined, majorFactorMap }) if (investChangedCount > 0) { updatedServiceIdSet.add(serviceId) updatedMethodCount += 1 updatedRowCount += investChangedCount } const landChangedCount = await syncScaleMethodFactors({ contractId, serviceId, method: 'landScale', syncConsultFactor, consultFactor, majorChangedRowIds: syncMajorFactor ? majorChangedRowIdSet : undefined, majorFactorMap }) if (landChangedCount > 0) { updatedServiceIdSet.add(serviceId) updatedMethodCount += 1 updatedRowCount += landChangedCount } if (syncConsultFactor) { const workloadChangedCount = await syncWorkloadMethodConsultFactor({ contractId, serviceId, consultFactor }) if (workloadChangedCount > 0) { updatedServiceIdSet.add(serviceId) updatedMethodCount += 1 updatedRowCount += workloadChangedCount } } } return { updatedServiceCount: updatedServiceIdSet.size, updatedMethodCount, updatedRowCount } } export const syncPricingTotalToZxFw = async (params: { contractId: string serviceId: string | number field: ZxFwPricingField value: number | null | undefined }) => { const store = useZxFwPricingStore() return store.updatePricingField(params) }