联动调整
This commit is contained in:
parent
a280dfb975
commit
e4c6203a98
@ -7,6 +7,8 @@ import { ScrollArea } from '@/components/ui/scroll-area'
|
|||||||
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
import { TooltipContent, TooltipProvider, TooltipRoot, TooltipTrigger } from '@/components/ui/tooltip'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
|
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
|
||||||
|
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
|
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
|
||||||
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
|
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
|
||||||
@ -51,10 +53,12 @@ interface ContractSegmentPackage {
|
|||||||
}
|
}
|
||||||
storage?: {
|
storage?: {
|
||||||
localforageEntries: DataEntry[]
|
localforageEntries: DataEntry[]
|
||||||
|
keyedEntries?: DataEntry[]
|
||||||
}
|
}
|
||||||
contracts: ContractItem[]
|
contracts: ContractItem[]
|
||||||
projectIndustry?: string
|
projectIndustry?: string
|
||||||
localforageEntries?: DataEntry[]
|
localforageEntries?: DataEntry[]
|
||||||
|
keyedEntries?: DataEntry[]
|
||||||
pinia?: {
|
pinia?: {
|
||||||
zxFwPricing?: {
|
zxFwPricing?: {
|
||||||
contracts?: Record<string, unknown>
|
contracts?: Record<string, unknown>
|
||||||
@ -127,6 +131,8 @@ const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly
|
|||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
const zxFwPricingStore = useZxFwPricingStore()
|
const zxFwPricingStore = useZxFwPricingStore()
|
||||||
|
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
|
||||||
|
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
|
||||||
const kvStore = useKvStore()
|
const kvStore = useKvStore()
|
||||||
|
|
||||||
|
|
||||||
@ -633,6 +639,7 @@ const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
|
|||||||
? payload.project.industry.trim()
|
? payload.project.industry.trim()
|
||||||
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
||||||
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
||||||
|
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
|
||||||
piniaState: payload.pinia ?? payload.piniaState
|
piniaState: payload.pinia ?? payload.piniaState
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -756,6 +763,13 @@ const isContractRelatedForageKey = (key: string, contractId: string) => {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isContractRelatedKeyedStateKey = (key: string, contractId: string) => {
|
||||||
|
if (key === `ht-base-info-${contractId}`) return true
|
||||||
|
if (key.startsWith(`work-content-${contractId}-`)) return true
|
||||||
|
if (key.startsWith(`work-content-htExtraFee-${contractId}-`)) return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
const readContractRelatedForageEntries = async (contractIds: string[]) => {
|
const readContractRelatedForageEntries = async (contractIds: string[]) => {
|
||||||
const keys = await kvStore.keys()
|
const keys = await kvStore.keys()
|
||||||
const idSet = new Set(contractIds)
|
const idSet = new Set(contractIds)
|
||||||
@ -773,6 +787,21 @@ const readContractRelatedForageEntries = async (contractIds: string[]) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const readContractRelatedKeyedEntries = (contractIds: string[]) => {
|
||||||
|
const idSet = new Set(contractIds.map(id => String(id || '').trim()).filter(Boolean))
|
||||||
|
return Object.entries(zxFwPricingStore.keyedStates)
|
||||||
|
.filter(([key]) => {
|
||||||
|
for (const id of idSet) {
|
||||||
|
if (isContractRelatedKeyedStateKey(key, id)) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
.map(([key, value]) => ({
|
||||||
|
key,
|
||||||
|
value: cloneJson(value)
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
|
const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) => {
|
||||||
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
|
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_KEY_PREFIX}${toId}`
|
||||||
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
|
if (key === `${SERVICE_KEY_PREFIX}${fromId}`) return `${SERVICE_KEY_PREFIX}${toId}`
|
||||||
@ -782,6 +811,13 @@ const rewriteKeyWithContractId = (key: string, fromId: string, toId: string) =>
|
|||||||
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
|
if (key === `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${fromId}`) {
|
||||||
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
|
return `${CONTRACT_MAJOR_FACTOR_KEY_PREFIX}${toId}`
|
||||||
}
|
}
|
||||||
|
if (key === `ht-base-info-${fromId}`) return `ht-base-info-${toId}`
|
||||||
|
if (key.startsWith(`work-content-${fromId}-`)) {
|
||||||
|
return key.replace(`work-content-${fromId}-`, `work-content-${toId}-`)
|
||||||
|
}
|
||||||
|
if (key.startsWith(`work-content-htExtraFee-${fromId}-`)) {
|
||||||
|
return key.replace(`work-content-htExtraFee-${fromId}-`, `work-content-htExtraFee-${toId}-`)
|
||||||
|
}
|
||||||
for (const prefix of PRICING_KEY_PREFIXES) {
|
for (const prefix of PRICING_KEY_PREFIXES) {
|
||||||
if (key.startsWith(`${prefix}${fromId}-`)) {
|
if (key.startsWith(`${prefix}${fromId}-`)) {
|
||||||
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
|
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
|
||||||
@ -817,6 +853,9 @@ const exportSelectedContracts = async () => {
|
|||||||
const localforageEntries = await readContractRelatedForageEntries(
|
const localforageEntries = await readContractRelatedForageEntries(
|
||||||
selectedContracts.map(item => item.id)
|
selectedContracts.map(item => item.id)
|
||||||
)
|
)
|
||||||
|
const keyedEntries = readContractRelatedKeyedEntries(
|
||||||
|
selectedContracts.map(item => item.id)
|
||||||
|
)
|
||||||
const piniaPayload = await buildContractPiniaPayload(selectedContracts.map(item => item.id))
|
const piniaPayload = await buildContractPiniaPayload(selectedContracts.map(item => item.id))
|
||||||
|
|
||||||
const projectIndustry = await getCurrentProjectIndustry()
|
const projectIndustry = await getCurrentProjectIndustry()
|
||||||
@ -835,7 +874,8 @@ const exportSelectedContracts = async () => {
|
|||||||
},
|
},
|
||||||
contracts: selectedContracts,
|
contracts: selectedContracts,
|
||||||
storage: {
|
storage: {
|
||||||
localforageEntries
|
localforageEntries,
|
||||||
|
keyedEntries
|
||||||
},
|
},
|
||||||
pinia: {
|
pinia: {
|
||||||
zxFwPricing: piniaPayload
|
zxFwPricing: piniaPayload
|
||||||
@ -898,6 +938,7 @@ const importContractSegments = async (event: Event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const importedEntries = normalizedPackage.localforageEntries
|
const importedEntries = normalizedPackage.localforageEntries
|
||||||
|
const importedKeyedEntries = normalizedPackage.keyedEntries
|
||||||
const usedIds = new Set(contracts.value.map(item => item.id))
|
const usedIds = new Set(contracts.value.map(item => item.id))
|
||||||
const oldToNewIdMap = new Map<string, string>()
|
const oldToNewIdMap = new Map<string, string>()
|
||||||
const nextContracts: ContractItem[] = importedContracts.map((item, index) => {
|
const nextContracts: ContractItem[] = importedContracts.map((item, index) => {
|
||||||
@ -923,11 +964,31 @@ const importContractSegments = async (event: Event) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const rewrittenKeyedEntries = importedKeyedEntries.map(entry => {
|
||||||
|
let nextKey = entry.key
|
||||||
|
for (const [oldId, newId] of oldToNewIdMap.entries()) {
|
||||||
|
if (!nextKey.includes(oldId)) continue
|
||||||
|
nextKey = rewriteKeyWithContractId(nextKey, oldId, newId)
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
key: nextKey,
|
||||||
|
value: entry.value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
await Promise.all(rewrittenEntries.map(entry => kvStore.setItem(entry.key, entry.value)))
|
await Promise.all(rewrittenEntries.map(entry => kvStore.setItem(entry.key, entry.value)))
|
||||||
|
for (const entry of rewrittenKeyedEntries) {
|
||||||
|
zxFwPricingStore.setKeyState(entry.key, cloneJson(entry.value), { force: true })
|
||||||
|
}
|
||||||
await applyImportedContractPiniaPayload(normalizedPackage.piniaState, oldToNewIdMap)
|
await applyImportedContractPiniaPayload(normalizedPackage.piniaState, oldToNewIdMap)
|
||||||
|
|
||||||
contracts.value = [...contracts.value, ...nextContracts]
|
contracts.value = [...contracts.value, ...nextContracts]
|
||||||
await saveContracts()
|
await saveContracts()
|
||||||
|
await Promise.all([
|
||||||
|
zxFwPricingStore.$persistNow?.(),
|
||||||
|
zxFwPricingKeysStore.$persistNow?.(),
|
||||||
|
zxFwPricingHtFeeStore.$persistNow?.()
|
||||||
|
])
|
||||||
await refreshContractBudgets()
|
await refreshContractBudgets()
|
||||||
notify(`导入成功(${nextContracts.length} 个合同段)`)
|
notify(`导入成功(${nextContracts.length} 个合同段)`)
|
||||||
await nextTick()
|
await nextTick()
|
||||||
@ -984,7 +1045,11 @@ const cleanupContractRelatedData = async (contractId: string) => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
removeForageKeysByContractId(kvStore, contractId),
|
removeForageKeysByContractId(kvStore, contractId),
|
||||||
])
|
])
|
||||||
await zxFwPricingStore.$persistNow?.()
|
await Promise.all([
|
||||||
|
zxFwPricingStore.$persistNow?.(),
|
||||||
|
zxFwPricingKeysStore.$persistNow?.(),
|
||||||
|
zxFwPricingHtFeeStore.$persistNow?.()
|
||||||
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
const loadContracts = async () => {
|
const loadContracts = async () => {
|
||||||
|
|||||||
@ -896,6 +896,7 @@ const applySelection = async (codes: string[]) => {
|
|||||||
|
|
||||||
const baseRows: DetailRow[] = uniqueIds
|
const baseRows: DetailRow[] = uniqueIds
|
||||||
.map<DetailRow | null>(id => {
|
.map<DetailRow | null>(id => {
|
||||||
|
|
||||||
const dictItem = serviceById.value.get(id)
|
const dictItem = serviceById.value.get(id)
|
||||||
if (!dictItem) return null
|
if (!dictItem) return null
|
||||||
|
|
||||||
@ -957,6 +958,7 @@ const applySelection = async (codes: string[]) => {
|
|||||||
* 服务勾选变化入口:先更新行,再刷新新增服务的计价汇总。
|
* 服务勾选变化入口:先更新行,再刷新新增服务的计价汇总。
|
||||||
*/
|
*/
|
||||||
const handleServiceSelectionChange = async (ids: string[]) => {
|
const handleServiceSelectionChange = async (ids: string[]) => {
|
||||||
|
|
||||||
const prevIds = [...selectedIds.value]
|
const prevIds = [...selectedIds.value]
|
||||||
await applySelection(ids)
|
await applySelection(ids)
|
||||||
const nextSelectedIds = getCurrentContractState().selectedIds || []
|
const nextSelectedIds = getCurrentContractState().selectedIds || []
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgG
|
|||||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||||
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||||
|
import { syncContractScaleToPricing } from '@/lib/zxFwPricingSync'
|
||||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
|
|
||||||
@ -41,6 +42,7 @@ interface XmBaseInfoState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
|
const XM_SCALE_FLUSH_EVENT = 'jgjs:xm-scale-flush-request'
|
||||||
|
const CONTRACT_SCALE_KEY_PREFIX = 'ht-info-v3-'
|
||||||
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
type MajorLite = { code: string; name: string; hasCost?: boolean; hasArea?: boolean }
|
||||||
const kvStore = useKvStore()
|
const kvStore = useKvStore()
|
||||||
|
|
||||||
@ -471,6 +473,12 @@ const saveToIndexedDB = async () => {
|
|||||||
payload.roughCalcEnabled = roughCalcEnabled.value
|
payload.roughCalcEnabled = roughCalcEnabled.value
|
||||||
payload.totalAmount = normalizedTotalAmount
|
payload.totalAmount = normalizedTotalAmount
|
||||||
await kvStore.setItem(props.dbKey, payload)
|
await kvStore.setItem(props.dbKey, payload)
|
||||||
|
if (props.dbKey.startsWith(CONTRACT_SCALE_KEY_PREFIX)) {
|
||||||
|
const contractId = props.dbKey.slice(CONTRACT_SCALE_KEY_PREFIX.length).trim()
|
||||||
|
if (contractId) {
|
||||||
|
await syncContractScaleToPricing(contractId)
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('saveToIndexedDB failed:', error)
|
console.error('saveToIndexedDB failed:', error)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,6 +35,7 @@ import {
|
|||||||
QUICK_CONTRACT_META_KEY,
|
QUICK_CONTRACT_META_KEY,
|
||||||
QUICK_MAJOR_FACTOR_KEY,
|
QUICK_MAJOR_FACTOR_KEY,
|
||||||
QUICK_PROJECT_INFO_KEY,
|
QUICK_PROJECT_INFO_KEY,
|
||||||
|
setPendingHomeImportFile,
|
||||||
writeWorkspaceMode
|
writeWorkspaceMode
|
||||||
} from '@/lib/workspace'
|
} from '@/lib/workspace'
|
||||||
|
|
||||||
@ -73,7 +74,6 @@ const kvStore = useKvStore()
|
|||||||
const projectDialogOpen = ref(false)
|
const projectDialogOpen = ref(false)
|
||||||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||||
const projectSubmitting = ref(false)
|
const projectSubmitting = ref(false)
|
||||||
const quickDialogOpen = ref(false)
|
|
||||||
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||||
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||||||
const quickSubmitting = ref(false)
|
const quickSubmitting = ref(false)
|
||||||
@ -161,19 +161,28 @@ const loadQuickDefaults = async () => {
|
|||||||
: QUICK_CONTRACT_FALLBACK_NAME
|
: QUICK_CONTRACT_FALLBACK_NAME
|
||||||
}
|
}
|
||||||
|
|
||||||
const openQuickCalcDialog = async () => {
|
const enterQuickCalc = (contractName: string) => {
|
||||||
|
writeWorkspaceMode('quick')
|
||||||
|
tabStore.enterWorkspace({
|
||||||
|
id: `contract-${QUICK_CONTRACT_ID}`,
|
||||||
|
title: contractName,
|
||||||
|
componentName: 'QuickCalcWorkbenchView',
|
||||||
|
props: {
|
||||||
|
contractId: QUICK_CONTRACT_ID,
|
||||||
|
contractName,
|
||||||
|
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||||||
|
projectScaleKey: null,
|
||||||
|
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
|
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
||||||
|
}
|
||||||
|
})
|
||||||
|
tabStore.hasCompletedSetup = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const openQuickCalc = async () => {
|
||||||
await loadQuickDefaults()
|
await loadQuickDefaults()
|
||||||
quickDialogOpen.value = true
|
const contractName = quickContractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME
|
||||||
}
|
|
||||||
|
|
||||||
const closeQuickCalcDialog = () => {
|
|
||||||
quickDialogOpen.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
const confirmQuickCalc = async () => {
|
|
||||||
const contractName = quickContractName.value.trim()
|
|
||||||
const industry = quickIndustry.value.trim()
|
const industry = quickIndustry.value.trim()
|
||||||
if (!contractName || !industry) return
|
|
||||||
|
|
||||||
quickSubmitting.value = true
|
quickSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
@ -188,31 +197,17 @@ const confirmQuickCalc = async () => {
|
|||||||
name: contractName,
|
name: contractName,
|
||||||
updatedAt: new Date().toISOString()
|
updatedAt: new Date().toISOString()
|
||||||
})
|
})
|
||||||
await initializeProjectFactorStates(
|
if (industry) {
|
||||||
kvStore,
|
await initializeProjectFactorStates(
|
||||||
industry,
|
kvStore,
|
||||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
industry,
|
||||||
QUICK_MAJOR_FACTOR_KEY
|
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||||
)
|
QUICK_MAJOR_FACTOR_KEY
|
||||||
|
)
|
||||||
writeWorkspaceMode('quick')
|
}
|
||||||
tabStore.enterWorkspace({
|
enterQuickCalc(contractName)
|
||||||
id: `contract-${QUICK_CONTRACT_ID}`,
|
|
||||||
title: contractName,
|
|
||||||
componentName: 'QuickCalcWorkbenchView',
|
|
||||||
props: {
|
|
||||||
contractId: QUICK_CONTRACT_ID,
|
|
||||||
contractName,
|
|
||||||
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
|
||||||
projectScaleKey: null,
|
|
||||||
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
|
||||||
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
|
||||||
}
|
|
||||||
})
|
|
||||||
tabStore.hasCompletedSetup = true
|
|
||||||
} finally {
|
} finally {
|
||||||
quickSubmitting.value = false
|
quickSubmitting.value = false
|
||||||
quickDialogOpen.value = false
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,6 +215,7 @@ const handleHomeImportChange = (event: Event) => {
|
|||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
setPendingHomeImportFile(file)
|
||||||
window.dispatchEvent(new CustomEvent('home-import-selected', {
|
window.dispatchEvent(new CustomEvent('home-import-selected', {
|
||||||
detail: {
|
detail: {
|
||||||
file
|
file
|
||||||
@ -303,9 +299,9 @@ onMounted(() => {
|
|||||||
role="button"
|
role="button"
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
class="home-card group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
class="home-card group flex cursor-pointer flex-col justify-between rounded-xl border border-slate-200/80 bg-white/95 px-5 py-5 shadow-[0_4px_20px_rgba(15,23,42,0.06)] backdrop-blur-sm transition-all duration-200 hover:-translate-y-0.5 hover:shadow-[0_12px_32px_rgba(15,23,42,0.12)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-200"
|
||||||
@click="openQuickCalcDialog"
|
@click="openQuickCalc"
|
||||||
@keydown.enter.prevent="openQuickCalcDialog"
|
@keydown.enter.prevent="openQuickCalc"
|
||||||
@keydown.space.prevent="openQuickCalcDialog"
|
@keydown.space.prevent="openQuickCalc"
|
||||||
>
|
>
|
||||||
<CardHeader class="p-0">
|
<CardHeader class="p-0">
|
||||||
<div
|
<div
|
||||||
@ -322,7 +318,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
<CardTitle class="mt-4 text-base font-semibold text-slate-900">单项速算</CardTitle>
|
<CardTitle class="mt-4 text-base font-semibold text-slate-900">单项速算</CardTitle>
|
||||||
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
|
<CardDescription class="mt-1.5 text-xs leading-5 text-slate-500">
|
||||||
单合同段预算、单项试算,选择行业与咨询类型,输入基数秒出结果
|
单项速算,选择行业与咨询类型,输入基数秒出结果
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
<div class="mt-4 flex items-center text-xs font-medium text-slate-400 transition-colors group-hover:text-slate-600">
|
||||||
@ -430,77 +426,6 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="quickDialogOpen"
|
|
||||||
class="fixed inset-0 z-[90] flex items-center justify-center bg-black/45 p-4"
|
|
||||||
@click.self="closeQuickCalcDialog"
|
|
||||||
>
|
|
||||||
<div class="w-full max-w-md rounded-xl border bg-background shadow-2xl">
|
|
||||||
<div class="flex items-center justify-between border-b px-5 py-4">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-base font-semibold text-foreground">单项速算</h3>
|
|
||||||
<p class="mt-1 text-sm text-muted-foreground">输入合同名称后,进入单项速算页面。</p>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="icon" class="h-8 w-8" @click="closeQuickCalcDialog">
|
|
||||||
<X class="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-4 px-5 py-4">
|
|
||||||
<label class="block space-y-2">
|
|
||||||
<span class="text-sm font-medium text-foreground">工程行业</span>
|
|
||||||
<SelectRoot v-model="quickIndustry">
|
|
||||||
<SelectTrigger
|
|
||||||
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
|
|
||||||
>
|
|
||||||
<SelectValue placeholder="请选择工程行业" />
|
|
||||||
<SelectIcon as-child>
|
|
||||||
<ChevronDown class="h-4 w-4 text-muted-foreground" />
|
|
||||||
</SelectIcon>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectPortal>
|
|
||||||
<SelectContent
|
|
||||||
:side-offset="6"
|
|
||||||
position="popper"
|
|
||||||
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
|
|
||||||
>
|
|
||||||
<SelectViewport class="p-1">
|
|
||||||
<SelectItem
|
|
||||||
v-for="item in industryTypeList"
|
|
||||||
:key="`quick-${item.id}`"
|
|
||||||
:value="String(item.id)"
|
|
||||||
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
|
||||||
>
|
|
||||||
<SelectItemText>{{ item.name }}</SelectItemText>
|
|
||||||
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
|
||||||
<Check class="h-4 w-4" />
|
|
||||||
</SelectItemIndicator>
|
|
||||||
</SelectItem>
|
|
||||||
</SelectViewport>
|
|
||||||
</SelectContent>
|
|
||||||
</SelectPortal>
|
|
||||||
</SelectRoot>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<label class="block space-y-2">
|
|
||||||
<span class="text-sm font-medium text-foreground">合同名称</span>
|
|
||||||
<input
|
|
||||||
v-model="quickContractName"
|
|
||||||
type="text"
|
|
||||||
class="inline-flex h-11 w-full items-center justify-between rounded-xl border border-border/70 bg-[linear-gradient(180deg,rgba(248,250,252,0.98),rgba(241,245,249,0.92))] px-4 text-sm text-foreground shadow-sm outline-none transition placeholder:text-slate-400 hover:border-border focus-visible:ring-2 focus-visible:ring-slate-300"
|
|
||||||
placeholder="请输入合同名称"
|
|
||||||
>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-end gap-2 border-t px-5 py-4">
|
|
||||||
<Button variant="outline" @click="closeQuickCalcDialog">取消</Button>
|
|
||||||
<Button :disabled="quickSubmitting || !quickContractName" @click="confirmQuickCalc">
|
|
||||||
{{ quickSubmitting ? '进入中...' : '进入计算' }}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
import { computed, onActivated, onMounted, ref, watch } from 'vue'
|
||||||
import { Check, Circle, CircleDot } from 'lucide-vue-next'
|
import { Check, ChevronDown, Circle, CircleDot } from 'lucide-vue-next'
|
||||||
import {
|
import {
|
||||||
getQuickCalcGroups,
|
getQuickCalcGroups,
|
||||||
getMajorDictEntries,
|
getMajorDictEntries,
|
||||||
@ -11,6 +11,19 @@ import { useKvStore } from '@/pinia/kv'
|
|||||||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||||
import { QUICK_PROJECT_INFO_KEY } from '@/lib/workspace'
|
import { QUICK_PROJECT_INFO_KEY } from '@/lib/workspace'
|
||||||
|
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||||||
|
import {
|
||||||
|
SelectContent,
|
||||||
|
SelectIcon,
|
||||||
|
SelectItem,
|
||||||
|
SelectItemIndicator,
|
||||||
|
SelectItemText,
|
||||||
|
SelectPortal,
|
||||||
|
SelectRoot,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
SelectViewport
|
||||||
|
} from 'reka-ui'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
contractId: string
|
contractId: string
|
||||||
@ -30,6 +43,8 @@ type DictFactorItem = {
|
|||||||
defCoe: number | null
|
defCoe: number | null
|
||||||
hasCost?: boolean | null
|
hasCost?: boolean | null
|
||||||
hasArea?: boolean | null
|
hasArea?: boolean | null
|
||||||
|
scale?: boolean | null
|
||||||
|
onlyCostScale?: boolean | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const kvStore = useKvStore()
|
const kvStore = useKvStore()
|
||||||
@ -42,7 +57,9 @@ const serviceDictIndex = new Map<string, DictFactorItem>(
|
|||||||
id: String(id),
|
id: String(id),
|
||||||
name: String(item?.name || ''),
|
name: String(item?.name || ''),
|
||||||
code: String(item?.code || ''),
|
code: String(item?.code || ''),
|
||||||
defCoe: typeof item?.defCoe === 'number' ? item.defCoe : null
|
defCoe: typeof item?.defCoe === 'number' ? item.defCoe : null,
|
||||||
|
scale: item?.scale === true,
|
||||||
|
onlyCostScale: item?.onlyCostScale === true
|
||||||
}
|
}
|
||||||
])
|
])
|
||||||
)
|
)
|
||||||
@ -69,15 +86,49 @@ const selectedMajor = ref<{ groupKey: string; label: string } | null>(null)
|
|||||||
const investScale = ref('')
|
const investScale = ref('')
|
||||||
const landScale = ref('')
|
const landScale = ref('')
|
||||||
const workEnvFactor = ref('1')
|
const workEnvFactor = ref('1')
|
||||||
const showSelectionHint = ref(true)
|
const industrySaving = ref(false)
|
||||||
|
let latestIndustryRequest = 0
|
||||||
|
|
||||||
|
const hasSelectedIndustry = computed(() => projectIndustry.value.trim() !== '')
|
||||||
|
const hasSelectedConsult = computed(() => selectedConsultLabel.value.trim() !== '')
|
||||||
|
|
||||||
|
const shouldShowMajorOption = (
|
||||||
|
group: { key: string },
|
||||||
|
option: { label: string; code: string | Record<string, string> }
|
||||||
|
) => {
|
||||||
|
if (!hasSelectedConsult.value) return false
|
||||||
|
if (!consultSupportsScale.value) return false
|
||||||
|
const code = resolveOptionCode(option)
|
||||||
|
if (!code) return false
|
||||||
|
const isLeafMajor = code.includes('-')
|
||||||
|
if (consultOnlySupportsCostScale.value) {
|
||||||
|
return group.key !== 'general' && !isLeafMajor
|
||||||
|
}
|
||||||
|
return isLeafMajor
|
||||||
|
}
|
||||||
|
|
||||||
const visibleGroups = computed(() => {
|
const visibleGroups = computed(() => {
|
||||||
const industry = projectIndustry.value.trim()
|
const industry = projectIndustry.value.trim()
|
||||||
return quickCalcGroups.filter(group => {
|
return quickCalcGroups
|
||||||
if (!group.industryId) return true
|
.filter(group => {
|
||||||
if (!industry) return true
|
if (group.key === 'consult') return true
|
||||||
return group.industryId === industry
|
if (!industry || !hasSelectedConsult.value) return false
|
||||||
})
|
if (!group.industryId) return true
|
||||||
|
return group.industryId === industry
|
||||||
|
})
|
||||||
|
.map(group => {
|
||||||
|
if (group.key === 'consult') return group
|
||||||
|
const filteredItems = group.items.filter(item => shouldShowMajorOption(group, item))
|
||||||
|
const visibleLabels = new Set(filteredItems.map(item => item.label))
|
||||||
|
return {
|
||||||
|
...group,
|
||||||
|
items: filteredItems,
|
||||||
|
rows: group.rows
|
||||||
|
.map(row => row.filter(label => visibleLabels.has(label)))
|
||||||
|
.filter(row => row.length > 0)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(group => group.key === 'consult' || group.items.length > 0)
|
||||||
})
|
})
|
||||||
|
|
||||||
const industryLabel = computed(() => {
|
const industryLabel = computed(() => {
|
||||||
@ -90,9 +141,14 @@ const selectedConsultOption = computed(() =>
|
|||||||
.find(item => item.key === 'consult')
|
.find(item => item.key === 'consult')
|
||||||
?.items.find(item => item.label === selectedConsultLabel.value) || null
|
?.items.find(item => item.label === selectedConsultLabel.value) || null
|
||||||
)
|
)
|
||||||
|
const selectedMajorGroup = computed(() =>
|
||||||
|
selectedMajor.value
|
||||||
|
? quickCalcGroups.find(item => item.key === selectedMajor.value?.groupKey) || null
|
||||||
|
: null
|
||||||
|
)
|
||||||
const selectedMajorOption = computed(() => {
|
const selectedMajorOption = computed(() => {
|
||||||
if (!selectedMajor.value) return null
|
if (!selectedMajor.value) return null
|
||||||
const group = quickCalcGroups.find(item => item.key === selectedMajor.value?.groupKey)
|
const group = visibleGroups.value.find(item => item.key === selectedMajor.value?.groupKey)
|
||||||
return group?.items.find(item => item.label === selectedMajor.value?.label) || null
|
return group?.items.find(item => item.label === selectedMajor.value?.label) || null
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -115,20 +171,36 @@ const selectedMajorDictItem = computed(() => {
|
|||||||
const consultCategoryFactor = computed(() => {
|
const consultCategoryFactor = computed(() => {
|
||||||
const serviceId = selectedConsultDictItem.value?.id
|
const serviceId = selectedConsultDictItem.value?.id
|
||||||
if (!serviceId) return null
|
if (!serviceId) return null
|
||||||
return consultFactorMap.value.get(serviceId) ?? selectedConsultDictItem.value?.defCoe ?? 1
|
return consultFactorMap.value.get(serviceId) ?? null
|
||||||
})
|
})
|
||||||
const defaultEngineeringMajorFactor = computed(() => {
|
const defaultEngineeringMajorFactor = computed(() => {
|
||||||
const majorId = selectedMajorDictItem.value?.id
|
const majorId = selectedMajorDictItem.value?.id
|
||||||
if (!majorId) return null
|
if (!majorId) return null
|
||||||
return majorFactorMap.value.get(majorId) ?? selectedMajorDictItem.value?.defCoe ?? 1
|
return majorFactorMap.value.get(majorId) ?? null
|
||||||
})
|
})
|
||||||
const engineeringMajorFactor = computed(() => defaultEngineeringMajorFactor.value)
|
const engineeringMajorFactor = computed(() => defaultEngineeringMajorFactor.value)
|
||||||
const workEnvCoefficient = computed(() => {
|
const workEnvCoefficient = computed(() => {
|
||||||
const parsed = Number(workEnvFactor.value)
|
const parsed = Number(workEnvFactor.value)
|
||||||
return Number.isFinite(parsed) ? parsed : null
|
return Number.isFinite(parsed) ? parsed : null
|
||||||
})
|
})
|
||||||
const canUseInvestScale = computed(() => selectedMajorDictItem.value?.hasCost === true)
|
const consultSupportsScale = computed(() => selectedConsultDictItem.value?.scale === true)
|
||||||
const canUseLandScale = computed(() => selectedMajorDictItem.value?.hasArea === true)
|
const consultOnlySupportsCostScale = computed(() => selectedConsultDictItem.value?.onlyCostScale === true)
|
||||||
|
const canUseInvestScale = computed(() => consultSupportsScale.value)
|
||||||
|
const canUseLandScale = computed(() =>
|
||||||
|
consultSupportsScale.value &&
|
||||||
|
!consultOnlySupportsCostScale.value
|
||||||
|
)
|
||||||
|
const investScalePlaceholder = computed(() => {
|
||||||
|
if (!selectedConsultLabel.value) return '请先选择咨询类别'
|
||||||
|
if (!consultSupportsScale.value) return '当前分类不适用规模法'
|
||||||
|
return '请输入'
|
||||||
|
})
|
||||||
|
const landScalePlaceholder = computed(() => {
|
||||||
|
if (!selectedConsultLabel.value) return '请先选择咨询类别'
|
||||||
|
if (!consultSupportsScale.value) return '当前分类不适用规模法'
|
||||||
|
if (consultOnlySupportsCostScale.value) return '当前分类仅支持投资规模'
|
||||||
|
return '请输入'
|
||||||
|
})
|
||||||
const activeScaleMode = computed<'cost' | 'area' | null>(() => {
|
const activeScaleMode = computed<'cost' | 'area' | null>(() => {
|
||||||
const hasInvestValue = investScale.value.trim() !== ''
|
const hasInvestValue = investScale.value.trim() !== ''
|
||||||
const hasLandValue = landScale.value.trim() !== ''
|
const hasLandValue = landScale.value.trim() !== ''
|
||||||
@ -146,7 +218,7 @@ const benchmarkSplit = computed(() => {
|
|||||||
})
|
})
|
||||||
const formulaText = computed(() => {
|
const formulaText = computed(() => {
|
||||||
const split = benchmarkSplit.value
|
const split = benchmarkSplit.value
|
||||||
if (!split) return '请先选择咨询类别、工程专业,并输入对应规模'
|
if (!split) return '请先选择工程行业、咨询类别、工程专业,并输入对应规模'
|
||||||
const parts = [split.basicFormula, split.optionalFormula].filter(Boolean)
|
const parts = [split.basicFormula, split.optionalFormula].filter(Boolean)
|
||||||
return parts.join(' + ') || '--'
|
return parts.join(' + ') || '--'
|
||||||
})
|
})
|
||||||
@ -213,12 +285,78 @@ const loadQuickIndustry = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const persistQuickIndustry = async (industry: string) => {
|
||||||
|
const currentInfo = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
|
||||||
|
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||||||
|
...currentInfo,
|
||||||
|
projectIndustry: industry,
|
||||||
|
projectName: '快速计算'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await Promise.all([loadQuickIndustry(), loadFactorDefaults()])
|
await Promise.all([loadQuickIndustry(), loadFactorDefaults()])
|
||||||
})
|
})
|
||||||
|
|
||||||
onActivated(() => {
|
onActivated(() => {
|
||||||
void loadFactorDefaults()
|
void Promise.all([loadQuickIndustry(), loadFactorDefaults()])
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => projectIndustry.value.trim(),
|
||||||
|
async (industry, previousIndustry) => {
|
||||||
|
if (industry === previousIndustry) return
|
||||||
|
|
||||||
|
if (selectedMajorGroup.value?.industryId && selectedMajorGroup.value.industryId !== industry) {
|
||||||
|
selectedMajor.value = null
|
||||||
|
}
|
||||||
|
if (!industry && selectedMajor.value) {
|
||||||
|
selectedMajor.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestId = ++latestIndustryRequest
|
||||||
|
industrySaving.value = true
|
||||||
|
try {
|
||||||
|
await persistQuickIndustry(industry)
|
||||||
|
if (industry) {
|
||||||
|
await initializeProjectFactorStates(
|
||||||
|
kvStore,
|
||||||
|
industry,
|
||||||
|
props.projectConsultCategoryFactorKey || '',
|
||||||
|
props.projectMajorFactorKey || ''
|
||||||
|
)
|
||||||
|
await loadFactorDefaults()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (requestId === latestIndustryRequest) {
|
||||||
|
industrySaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[hasSelectedConsult, consultSupportsScale, consultOnlySupportsCostScale],
|
||||||
|
() => {
|
||||||
|
if (!selectedMajor.value) return
|
||||||
|
const group = visibleGroups.value.find(item => item.key === selectedMajor.value?.groupKey)
|
||||||
|
const stillVisible = group?.items.some(item => item.label === selectedMajor.value?.label) === true
|
||||||
|
if (!stillVisible) {
|
||||||
|
selectedMajor.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
watch(canUseInvestScale, enabled => {
|
||||||
|
if (!enabled) {
|
||||||
|
investScale.value = ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(canUseLandScale, enabled => {
|
||||||
|
if (!enabled) {
|
||||||
|
landScale.value = ''
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@ -237,17 +375,56 @@ onActivated(() => {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<Transition name="quick-calc-hint">
|
<div class="quick-calc-toolbar">
|
||||||
<div v-if="showSelectionHint" class="quick-calc-hint-banner">
|
<label class="quick-calc-toolbar__field">
|
||||||
<div class="quick-calc-hint-banner__content">
|
<span class="quick-calc-field__label">工程行业</span>
|
||||||
<div class="quick-calc-hint-banner__title">先选咨询类别</div>
|
<SelectRoot v-model="projectIndustry">
|
||||||
<div class="quick-calc-hint-banner__text">再从通用专业或工程专业中选择一项继续计算。</div>
|
<SelectTrigger class="quick-calc-toolbar__trigger">
|
||||||
</div>
|
<SelectValue placeholder="请选择工程行业" />
|
||||||
<button type="button" class="quick-calc-hint-banner__action" @click="showSelectionHint = false">
|
<SelectIcon as-child>
|
||||||
<Check class="h-3.5 w-3.5" />
|
<ChevronDown class="h-4 w-4 text-[var(--qc-muted)]" />
|
||||||
</button>
|
</SelectIcon>
|
||||||
</div>
|
</SelectTrigger>
|
||||||
</Transition>
|
<SelectPortal>
|
||||||
|
<SelectContent
|
||||||
|
:side-offset="6"
|
||||||
|
position="popper"
|
||||||
|
class="z-[120] w-[var(--reka-select-trigger-width)] overflow-hidden rounded-xl border border-border bg-popover text-popover-foreground shadow-xl"
|
||||||
|
>
|
||||||
|
<SelectViewport class="p-1">
|
||||||
|
<SelectItem
|
||||||
|
v-for="item in industryTypeList"
|
||||||
|
:key="`quick-workbench-${item.id}`"
|
||||||
|
:value="String(item.id)"
|
||||||
|
class="relative flex h-9 w-full cursor-default select-none items-center rounded-md pl-3 pr-8 text-sm outline-none data-[highlighted]:bg-muted data-[highlighted]:text-foreground data-[state=checked]:bg-slate-100"
|
||||||
|
>
|
||||||
|
<SelectItemText>{{ item.name }}</SelectItemText>
|
||||||
|
<SelectItemIndicator class="absolute right-2 inline-flex items-center text-slate-700">
|
||||||
|
<Check class="h-4 w-4" />
|
||||||
|
</SelectItemIndicator>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectViewport>
|
||||||
|
</SelectContent>
|
||||||
|
</SelectPortal>
|
||||||
|
</SelectRoot>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<span class="quick-calc-toolbar__meta">
|
||||||
|
{{ industrySaving ? '保存中...' : hasSelectedIndustry ? '已同步行业' : '未选择行业' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!hasSelectedIndustry" class="quick-calc-empty-state">
|
||||||
|
请选择工程行业。选中后可先选择咨询类别,再显示对应专业分类。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!hasSelectedConsult" class="quick-calc-empty-state">
|
||||||
|
请先选择咨询类别。选中后才会显示匹配的通用专业和工程专业分类。
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="!consultSupportsScale" class="quick-calc-empty-state">
|
||||||
|
当前咨询类别不适用规模法,因此不显示专业分类。
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="quick-calc-catalog">
|
<div class="quick-calc-catalog">
|
||||||
<article
|
<article
|
||||||
@ -344,9 +521,9 @@ onActivated(() => {
|
|||||||
<input
|
<input
|
||||||
v-model="investScale"
|
v-model="investScale"
|
||||||
class="quick-calc-field__input"
|
class="quick-calc-field__input"
|
||||||
:class="{ 'is-disabled': selectedMajor !== null && !canUseInvestScale }"
|
:class="{ 'is-disabled': !canUseInvestScale }"
|
||||||
:disabled="selectedMajor !== null && !canUseInvestScale"
|
:disabled="!canUseInvestScale"
|
||||||
:placeholder="selectedMajor !== null && !canUseInvestScale ? '当前专业不适用' : '请输入'"
|
:placeholder="investScalePlaceholder"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -355,9 +532,9 @@ onActivated(() => {
|
|||||||
<input
|
<input
|
||||||
v-model="landScale"
|
v-model="landScale"
|
||||||
class="quick-calc-field__input"
|
class="quick-calc-field__input"
|
||||||
:class="{ 'is-disabled': selectedMajor !== null && !canUseLandScale }"
|
:class="{ 'is-disabled': !canUseLandScale }"
|
||||||
:disabled="selectedMajor !== null && !canUseLandScale"
|
:disabled="!canUseLandScale"
|
||||||
:placeholder="selectedMajor !== null && !canUseLandScale ? '当前专业不适用' : '请输入'"
|
:placeholder="landScalePlaceholder"
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
@ -399,7 +576,7 @@ onActivated(() => {
|
|||||||
<label class="quick-calc-field">
|
<label class="quick-calc-field">
|
||||||
<span class="quick-calc-field__label">工程专业系数</span>
|
<span class="quick-calc-field__label">工程专业系数</span>
|
||||||
<div class="quick-calc-field__readonly">
|
<div class="quick-calc-field__readonly">
|
||||||
{{ engineeringMajorFactor == null ? '1.000' : engineeringMajorFactor.toFixed(3) }}
|
{{ engineeringMajorFactor == null ? '--' : engineeringMajorFactor.toFixed(3) }}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
@ -472,6 +649,58 @@ onActivated(() => {
|
|||||||
linear-gradient(180deg, color-mix(in srgb, white 20%, transparent) 0%, transparent 100%);
|
linear-gradient(180deg, color-mix(in srgb, white 20%, transparent) 0%, transparent 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-calc-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
border-bottom: 1px solid var(--qc-border);
|
||||||
|
background: color-mix(in srgb, var(--qc-surface-muted) 72%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-calc-toolbar__field {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: min(280px, 100%);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-calc-toolbar__trigger {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 42px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--qc-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in srgb, white 55%, var(--qc-surface));
|
||||||
|
color: var(--qc-text);
|
||||||
|
box-shadow: inset 0 1px 0 color-mix(in srgb, white 80%, transparent);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-calc-toolbar__trigger:focus-visible {
|
||||||
|
border-color: var(--qc-border-strong);
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, hsl(var(--destructive)) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-calc-toolbar__meta {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--qc-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-calc-empty-state {
|
||||||
|
margin: 12px 12px 0;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px dashed var(--qc-border-strong);
|
||||||
|
border-radius: 14px;
|
||||||
|
color: var(--qc-muted);
|
||||||
|
background: color-mix(in srgb, var(--qc-surface-muted) 60%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
.quick-calc-panel__title-wrap {
|
.quick-calc-panel__title-wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import type { ComponentPublicInstance } from 'vue'
|
|||||||
import draggable from 'vuedraggable'
|
import draggable from 'vuedraggable'
|
||||||
import { useTabStore } from '@/pinia/tab'
|
import { useTabStore } from '@/pinia/tab'
|
||||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||||
|
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
|
||||||
|
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
|
||||||
import { useKvStore } from '@/pinia/kv'
|
import { useKvStore } from '@/pinia/kv'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
@ -27,7 +29,13 @@ import {
|
|||||||
ToastViewport
|
ToastViewport
|
||||||
} from 'reka-ui'
|
} from 'reka-ui'
|
||||||
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
import { decodeZwArchive, encodeZwArchive, ZW_FILE_EXTENSION } from '@/lib/zwArchive'
|
||||||
import { PROJECT_TAB_ID, QUICK_TAB_ID, readWorkspaceMode, writeWorkspaceMode } from '@/lib/workspace'
|
import {
|
||||||
|
PROJECT_TAB_ID,
|
||||||
|
QUICK_TAB_ID,
|
||||||
|
consumePendingHomeImportFile,
|
||||||
|
readWorkspaceMode,
|
||||||
|
writeWorkspaceMode
|
||||||
|
} from '@/lib/workspace'
|
||||||
import { addNumbers, roundTo } from '@/lib/decimal'
|
import { addNumbers, roundTo } from '@/lib/decimal'
|
||||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||||
import { exportFile, serviceList } from '@/sql'
|
import { exportFile, serviceList } from '@/sql'
|
||||||
@ -404,7 +412,7 @@ const CONSULT_CATEGORY_FACTOR_DB_KEY = 'xm-consult-category-factor-v1'
|
|||||||
const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1'
|
const MAJOR_FACTOR_DB_KEY = 'xm-major-factor-v1'
|
||||||
const PINIA_PERSIST_DB_NAME = 'DB'
|
const PINIA_PERSIST_DB_NAME = 'DB'
|
||||||
const PINIA_PERSIST_BASE_STORE_NAME = 'pinia'
|
const PINIA_PERSIST_BASE_STORE_NAME = 'pinia'
|
||||||
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'kv'] as const
|
const PINIA_PERSIST_STORE_IDS = ['tabs', 'zxFwPricing', 'zxFwPricingKeys', 'zxFwPricingHtFee', 'kv'] as const
|
||||||
const RESET_MIN_LOADING_MS = 1000
|
const RESET_MIN_LOADING_MS = 1000
|
||||||
const userGuideSteps: UserGuideStep[] = [
|
const userGuideSteps: UserGuideStep[] = [
|
||||||
{
|
{
|
||||||
@ -491,6 +499,8 @@ const componentMap: Record<string, any> = {
|
|||||||
|
|
||||||
const tabStore = useTabStore()
|
const tabStore = useTabStore()
|
||||||
const zxFwPricingStore = useZxFwPricingStore()
|
const zxFwPricingStore = useZxFwPricingStore()
|
||||||
|
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
|
||||||
|
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
|
||||||
const kvStore = useKvStore()
|
const kvStore = useKvStore()
|
||||||
|
|
||||||
|
|
||||||
@ -1859,20 +1869,24 @@ const triggerImport = () => {
|
|||||||
importFileRef.value?.click()
|
importFileRef.value?.click()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const prepareImportPayloadFromFile = async (file: File) => {
|
||||||
|
if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) {
|
||||||
|
throw new Error('INVALID_FILE_EXT')
|
||||||
|
}
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const payload = await decodeZwArchive<DataPackage>(buffer)
|
||||||
|
pendingImportPayload.value = payload
|
||||||
|
pendingImportFileName.value = file.name
|
||||||
|
importConfirmOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
const importData = async (event: Event) => {
|
const importData = async (event: Event) => {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (!file.name.toLowerCase().endsWith(ZW_FILE_EXTENSION)) {
|
await prepareImportPayloadFromFile(file)
|
||||||
throw new Error('INVALID_FILE_EXT')
|
|
||||||
}
|
|
||||||
const buffer = await file.arrayBuffer()
|
|
||||||
const payload = await decodeZwArchive<DataPackage>(buffer)
|
|
||||||
pendingImportPayload.value = payload
|
|
||||||
pendingImportFileName.value = file.name
|
|
||||||
importConfirmOpen.value = true
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('import failed:', error)
|
console.error('import failed:', error)
|
||||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||||
@ -1922,6 +1936,16 @@ const confirmImportOverride = async () => {
|
|||||||
zxFwPricingStore.$patch(zxFwPricingState as any)
|
zxFwPricingStore.$patch(zxFwPricingState as any)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const zxFwPricingKeysState = readPersistedState('zxFwPricingKeys')
|
||||||
|
if (zxFwPricingKeysState) {
|
||||||
|
zxFwPricingKeysStore.$patch(zxFwPricingKeysState as any)
|
||||||
|
}
|
||||||
|
|
||||||
|
const zxFwPricingHtFeeState = readPersistedState('zxFwPricingHtFee')
|
||||||
|
if (zxFwPricingHtFeeState) {
|
||||||
|
zxFwPricingHtFeeStore.$patch(zxFwPricingHtFeeState as any)
|
||||||
|
}
|
||||||
|
|
||||||
const kvState = readPersistedState('kv')
|
const kvState = readPersistedState('kv')
|
||||||
if (kvState) {
|
if (kvState) {
|
||||||
kvStore.$patch(kvState as any)
|
kvStore.$patch(kvState as any)
|
||||||
@ -1930,6 +1954,8 @@ const confirmImportOverride = async () => {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
tabStore.$persistNow?.(),
|
tabStore.$persistNow?.(),
|
||||||
zxFwPricingStore.$persistNow?.(),
|
zxFwPricingStore.$persistNow?.(),
|
||||||
|
zxFwPricingKeysStore.$persistNow?.(),
|
||||||
|
zxFwPricingHtFeeStore.$persistNow?.(),
|
||||||
kvStore.$persistNow?.()
|
kvStore.$persistNow?.()
|
||||||
])
|
])
|
||||||
dataMenuOpen.value = false
|
dataMenuOpen.value = false
|
||||||
@ -2025,6 +2051,7 @@ onMounted(() => {
|
|||||||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||||
|
const pendingHomeImportFile = consumePendingHomeImportFile()
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
bindTabStripScroll()
|
bindTabStripScroll()
|
||||||
ensureActiveTabVisible()
|
ensureActiveTabVisible()
|
||||||
@ -2032,6 +2059,12 @@ onMounted(() => {
|
|||||||
scheduleUpdateTabTitleOverflow()
|
scheduleUpdateTabTitleOverflow()
|
||||||
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
|
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
|
||||||
})
|
})
|
||||||
|
if (pendingHomeImportFile) {
|
||||||
|
void prepareImportPayloadFromFile(pendingHomeImportFile).catch(error => {
|
||||||
|
console.error('home import failed:', error)
|
||||||
|
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||||
|
})
|
||||||
|
}
|
||||||
void (async () => {
|
void (async () => {
|
||||||
if (await shouldAutoOpenGuide()) {
|
if (await shouldAutoOpenGuide()) {
|
||||||
openUserGuide(0)
|
openUserGuide(0)
|
||||||
|
|||||||
@ -9,7 +9,7 @@ export const WORKSPACE_MODE_STORAGE_KEY = 'jgjs-workspace-mode-v1'
|
|||||||
|
|
||||||
export const QUICK_CONTRACT_ID = 'quick-contract-default'
|
export const QUICK_CONTRACT_ID = 'quick-contract-default'
|
||||||
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
|
export const QUICK_CONTRACT_META_KEY = 'quick-contract-meta-v1'
|
||||||
export const QUICK_CONTRACT_FALLBACK_NAME = '快速计算合同'
|
export const QUICK_CONTRACT_FALLBACK_NAME = '快速计算'
|
||||||
export const QUICK_CONTRACT_TAB_ID = `contract-${QUICK_CONTRACT_ID}`
|
export const QUICK_CONTRACT_TAB_ID = `contract-${QUICK_CONTRACT_ID}`
|
||||||
|
|
||||||
export const QUICK_PROJECT_INFO_KEY = 'quick-xm-base-info-v1'
|
export const QUICK_PROJECT_INFO_KEY = 'quick-xm-base-info-v1'
|
||||||
@ -17,6 +17,8 @@ export const QUICK_PROJECT_SCALE_KEY = 'quick-xm-info-v3'
|
|||||||
export const QUICK_CONSULT_CATEGORY_FACTOR_KEY = 'quick-xm-consult-category-factor-v1'
|
export const QUICK_CONSULT_CATEGORY_FACTOR_KEY = 'quick-xm-consult-category-factor-v1'
|
||||||
export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1'
|
export const QUICK_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1'
|
||||||
|
|
||||||
|
let pendingHomeImportFile: File | null = null
|
||||||
|
|
||||||
export interface QuickContractMeta {
|
export interface QuickContractMeta {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
@ -47,3 +49,13 @@ export const writeWorkspaceMode = (mode: WorkspaceMode) => {
|
|||||||
// 忽略只读或隐私模式下的写入失败。
|
// 忽略只读或隐私模式下的写入失败。
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const setPendingHomeImportFile = (file: File | null) => {
|
||||||
|
pendingHomeImportFile = file
|
||||||
|
}
|
||||||
|
|
||||||
|
export const consumePendingHomeImportFile = () => {
|
||||||
|
const file = pendingHomeImportFile
|
||||||
|
pendingHomeImportFile = null
|
||||||
|
return file
|
||||||
|
}
|
||||||
|
|||||||
@ -1,7 +1,286 @@
|
|||||||
import { useZxFwPricingStore, type ZxFwPricingField } from '@/pinia/zxFwPricing'
|
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'
|
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: {
|
export const syncPricingTotalToZxFw = async (params: {
|
||||||
contractId: string
|
contractId: string
|
||||||
serviceId: string | number
|
serviceId: string | number
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user