519 lines
16 KiB
TypeScript
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)
|
|
}
|