calculator2026/src/lib/zxFwPricingSync.ts
2026-06-25 09:28:28 +08:00

519 lines
16 KiB
TypeScript

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<string | number>) =>
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<string>,
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<string, ScaleDetailRow>
sourceRowIdMap: Map<string, ScaleDetailRow>
projectTotals: Map<number, { amount: number | null; landArea: number | null }>
changedRowIds?: Set<string>
}) => {
const store = useZxFwPricingStore()
const methodState = await store.loadServicePricingMethodState<ScaleDetailRow>(
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<ContractScaleSyncResult> => {
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<string>()
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<string>
majorFactorMap: Map<string, number | null>
}) => {
const store = useZxFwPricingStore()
const methodState = await store.loadServicePricingMethodState<ScaleDetailRow>(
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<WorkloadDetailRow>(
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<ContractFactorSyncResult> => {
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<string>()
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)
}