JGJS2026/src/lib/zxFwPricingSync.ts
2026-03-23 17:09:32 +08:00

293 lines
9.9 KiB
TypeScript

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<ScaleDetailRow> | 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<ScaleDetailRow> | 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<string, ScaleDetailRow>()
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<string, ScaleDetailRow>) => {
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<string, ScaleDetailRow>
onlyCostScaleFallbackAmount?: number | null
isOnlyCostScaleService?: boolean
}) => {
const store = useZxFwPricingStore()
const methodState = await store.loadServicePricingMethodState<ScaleDetailRow>(
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)
}