293 lines
9.9 KiB
TypeScript
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)
|
|
}
|