联动调整
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 { useTabStore } from '@/pinia/tab'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
|
||||
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { ArrowUp, Edit3, GripVertical, MoreHorizontal, Plus, Trash2, X } from 'lucide-vue-next'
|
||||
import { decodeZwArchive, encodeZwArchive } from '@/lib/zwArchive'
|
||||
@ -51,10 +53,12 @@ interface ContractSegmentPackage {
|
||||
}
|
||||
storage?: {
|
||||
localforageEntries: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
}
|
||||
contracts: ContractItem[]
|
||||
projectIndustry?: string
|
||||
localforageEntries?: DataEntry[]
|
||||
keyedEntries?: DataEntry[]
|
||||
pinia?: {
|
||||
zxFwPricing?: {
|
||||
contracts?: Record<string, unknown>
|
||||
@ -127,6 +131,8 @@ const SERVICE_PRICING_METHODS = ['investScale', 'landScale', 'workload', 'hourly
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
|
||||
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
|
||||
const kvStore = useKvStore()
|
||||
|
||||
|
||||
@ -633,6 +639,7 @@ const normalizeContractSegmentPackage = (payload: ContractSegmentPackage) => ({
|
||||
? payload.project.industry.trim()
|
||||
: (typeof payload.projectIndustry === 'string' ? payload.projectIndustry.trim() : ''),
|
||||
localforageEntries: normalizeDataEntries(payload.storage?.localforageEntries ?? payload.localforageEntries),
|
||||
keyedEntries: normalizeDataEntries(payload.storage?.keyedEntries ?? payload.keyedEntries),
|
||||
piniaState: payload.pinia ?? payload.piniaState
|
||||
})
|
||||
|
||||
@ -756,6 +763,13 @@ const isContractRelatedForageKey = (key: string, contractId: string) => {
|
||||
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 keys = await kvStore.keys()
|
||||
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) => {
|
||||
if (key === `${CONTRACT_KEY_PREFIX}${fromId}`) return `${CONTRACT_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}`) {
|
||||
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) {
|
||||
if (key.startsWith(`${prefix}${fromId}-`)) {
|
||||
return key.replace(`${prefix}${fromId}-`, `${prefix}${toId}-`)
|
||||
@ -817,6 +853,9 @@ const exportSelectedContracts = async () => {
|
||||
const localforageEntries = await readContractRelatedForageEntries(
|
||||
selectedContracts.map(item => item.id)
|
||||
)
|
||||
const keyedEntries = readContractRelatedKeyedEntries(
|
||||
selectedContracts.map(item => item.id)
|
||||
)
|
||||
const piniaPayload = await buildContractPiniaPayload(selectedContracts.map(item => item.id))
|
||||
|
||||
const projectIndustry = await getCurrentProjectIndustry()
|
||||
@ -835,7 +874,8 @@ const exportSelectedContracts = async () => {
|
||||
},
|
||||
contracts: selectedContracts,
|
||||
storage: {
|
||||
localforageEntries
|
||||
localforageEntries,
|
||||
keyedEntries
|
||||
},
|
||||
pinia: {
|
||||
zxFwPricing: piniaPayload
|
||||
@ -898,6 +938,7 @@ const importContractSegments = async (event: Event) => {
|
||||
}
|
||||
|
||||
const importedEntries = normalizedPackage.localforageEntries
|
||||
const importedKeyedEntries = normalizedPackage.keyedEntries
|
||||
const usedIds = new Set(contracts.value.map(item => item.id))
|
||||
const oldToNewIdMap = new Map<string, string>()
|
||||
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)))
|
||||
for (const entry of rewrittenKeyedEntries) {
|
||||
zxFwPricingStore.setKeyState(entry.key, cloneJson(entry.value), { force: true })
|
||||
}
|
||||
await applyImportedContractPiniaPayload(normalizedPackage.piniaState, oldToNewIdMap)
|
||||
|
||||
contracts.value = [...contracts.value, ...nextContracts]
|
||||
await saveContracts()
|
||||
await Promise.all([
|
||||
zxFwPricingStore.$persistNow?.(),
|
||||
zxFwPricingKeysStore.$persistNow?.(),
|
||||
zxFwPricingHtFeeStore.$persistNow?.()
|
||||
])
|
||||
await refreshContractBudgets()
|
||||
notify(`导入成功(${nextContracts.length} 个合同段)`)
|
||||
await nextTick()
|
||||
@ -984,7 +1045,11 @@ const cleanupContractRelatedData = async (contractId: string) => {
|
||||
await Promise.all([
|
||||
removeForageKeysByContractId(kvStore, contractId),
|
||||
])
|
||||
await zxFwPricingStore.$persistNow?.()
|
||||
await Promise.all([
|
||||
zxFwPricingStore.$persistNow?.(),
|
||||
zxFwPricingKeysStore.$persistNow?.(),
|
||||
zxFwPricingHtFeeStore.$persistNow?.()
|
||||
])
|
||||
}
|
||||
|
||||
const loadContracts = async () => {
|
||||
|
||||
@ -896,6 +896,7 @@ const applySelection = async (codes: string[]) => {
|
||||
|
||||
const baseRows: DetailRow[] = uniqueIds
|
||||
.map<DetailRow | null>(id => {
|
||||
|
||||
const dictItem = serviceById.value.get(id)
|
||||
if (!dictItem) return null
|
||||
|
||||
@ -957,6 +958,7 @@ const applySelection = async (codes: string[]) => {
|
||||
* 服务勾选变化入口:先更新行,再刷新新增服务的计价汇总。
|
||||
*/
|
||||
const handleServiceSelectionChange = async (ids: string[]) => {
|
||||
|
||||
const prevIds = [...selectedIds.value]
|
||||
await applySelection(ids)
|
||||
const nextSelectedIds = getCurrentContractState().selectedIds || []
|
||||
|
||||
@ -7,6 +7,7 @@ import { myTheme, gridOptions, agGridWrapClass, agGridStyle } from '@/lib/diyAgG
|
||||
import { decimalAggSum, roundTo, sumByNumber } from '@/lib/decimal'
|
||||
import { formatThousandsFlexible } from '@/lib/numberFormat'
|
||||
import { industryTypeList, getMajorDictEntries, isMajorIdInIndustryScope } from '@/sql'
|
||||
import { syncContractScaleToPricing } from '@/lib/zxFwPricingSync'
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
|
||||
@ -41,6 +42,7 @@ interface XmBaseInfoState {
|
||||
}
|
||||
|
||||
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 }
|
||||
const kvStore = useKvStore()
|
||||
|
||||
@ -471,6 +473,12 @@ const saveToIndexedDB = async () => {
|
||||
payload.roughCalcEnabled = roughCalcEnabled.value
|
||||
payload.totalAmount = normalizedTotalAmount
|
||||
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) {
|
||||
console.error('saveToIndexedDB failed:', error)
|
||||
}
|
||||
|
||||
@ -35,6 +35,7 @@ import {
|
||||
QUICK_CONTRACT_META_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY,
|
||||
QUICK_PROJECT_INFO_KEY,
|
||||
setPendingHomeImportFile,
|
||||
writeWorkspaceMode
|
||||
} from '@/lib/workspace'
|
||||
|
||||
@ -73,7 +74,6 @@ const kvStore = useKvStore()
|
||||
const projectDialogOpen = ref(false)
|
||||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const projectSubmitting = ref(false)
|
||||
const quickDialogOpen = ref(false)
|
||||
const quickIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||||
const quickContractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||||
const quickSubmitting = ref(false)
|
||||
@ -161,19 +161,28 @@ const loadQuickDefaults = async () => {
|
||||
: 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()
|
||||
quickDialogOpen.value = true
|
||||
}
|
||||
|
||||
const closeQuickCalcDialog = () => {
|
||||
quickDialogOpen.value = false
|
||||
}
|
||||
|
||||
const confirmQuickCalc = async () => {
|
||||
const contractName = quickContractName.value.trim()
|
||||
const contractName = quickContractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME
|
||||
const industry = quickIndustry.value.trim()
|
||||
if (!contractName || !industry) return
|
||||
|
||||
quickSubmitting.value = true
|
||||
try {
|
||||
@ -188,31 +197,17 @@ const confirmQuickCalc = async () => {
|
||||
name: contractName,
|
||||
updatedAt: new Date().toISOString()
|
||||
})
|
||||
await initializeProjectFactorStates(
|
||||
kvStore,
|
||||
industry,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY
|
||||
)
|
||||
|
||||
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
|
||||
if (industry) {
|
||||
await initializeProjectFactorStates(
|
||||
kvStore,
|
||||
industry,
|
||||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||||
QUICK_MAJOR_FACTOR_KEY
|
||||
)
|
||||
}
|
||||
enterQuickCalc(contractName)
|
||||
} finally {
|
||||
quickSubmitting.value = false
|
||||
quickDialogOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -220,6 +215,7 @@ const handleHomeImportChange = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
setPendingHomeImportFile(file)
|
||||
window.dispatchEvent(new CustomEvent('home-import-selected', {
|
||||
detail: {
|
||||
file
|
||||
@ -303,9 +299,9 @@ onMounted(() => {
|
||||
role="button"
|
||||
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"
|
||||
@click="openQuickCalcDialog"
|
||||
@keydown.enter.prevent="openQuickCalcDialog"
|
||||
@keydown.space.prevent="openQuickCalcDialog"
|
||||
@click="openQuickCalc"
|
||||
@keydown.enter.prevent="openQuickCalc"
|
||||
@keydown.space.prevent="openQuickCalc"
|
||||
>
|
||||
<CardHeader class="p-0">
|
||||
<div
|
||||
@ -322,7 +318,7 @@ onMounted(() => {
|
||||
</div>
|
||||
<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>
|
||||
</CardHeader>
|
||||
<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
|
||||
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>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onActivated, onMounted, ref } from 'vue'
|
||||
import { Check, Circle, CircleDot } from 'lucide-vue-next'
|
||||
import { computed, onActivated, onMounted, ref, watch } from 'vue'
|
||||
import { Check, ChevronDown, Circle, CircleDot } from 'lucide-vue-next'
|
||||
import {
|
||||
getQuickCalcGroups,
|
||||
getMajorDictEntries,
|
||||
@ -11,6 +11,19 @@ import { useKvStore } from '@/pinia/kv'
|
||||
import { loadConsultCategoryFactorMap, loadMajorFactorMap } from '@/lib/xmFactorDefaults'
|
||||
import { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
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<{
|
||||
contractId: string
|
||||
@ -30,6 +43,8 @@ type DictFactorItem = {
|
||||
defCoe: number | null
|
||||
hasCost?: boolean | null
|
||||
hasArea?: boolean | null
|
||||
scale?: boolean | null
|
||||
onlyCostScale?: boolean | null
|
||||
}
|
||||
|
||||
const kvStore = useKvStore()
|
||||
@ -42,7 +57,9 @@ const serviceDictIndex = new Map<string, DictFactorItem>(
|
||||
id: String(id),
|
||||
name: String(item?.name || ''),
|
||||
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 landScale = ref('')
|
||||
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 industry = projectIndustry.value.trim()
|
||||
return quickCalcGroups.filter(group => {
|
||||
if (!group.industryId) return true
|
||||
if (!industry) return true
|
||||
return group.industryId === industry
|
||||
})
|
||||
return quickCalcGroups
|
||||
.filter(group => {
|
||||
if (group.key === 'consult') return true
|
||||
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(() => {
|
||||
@ -90,9 +141,14 @@ const selectedConsultOption = computed(() =>
|
||||
.find(item => item.key === 'consult')
|
||||
?.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(() => {
|
||||
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
|
||||
})
|
||||
|
||||
@ -115,20 +171,36 @@ const selectedMajorDictItem = computed(() => {
|
||||
const consultCategoryFactor = computed(() => {
|
||||
const serviceId = selectedConsultDictItem.value?.id
|
||||
if (!serviceId) return null
|
||||
return consultFactorMap.value.get(serviceId) ?? selectedConsultDictItem.value?.defCoe ?? 1
|
||||
return consultFactorMap.value.get(serviceId) ?? null
|
||||
})
|
||||
const defaultEngineeringMajorFactor = computed(() => {
|
||||
const majorId = selectedMajorDictItem.value?.id
|
||||
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 workEnvCoefficient = computed(() => {
|
||||
const parsed = Number(workEnvFactor.value)
|
||||
return Number.isFinite(parsed) ? parsed : null
|
||||
})
|
||||
const canUseInvestScale = computed(() => selectedMajorDictItem.value?.hasCost === true)
|
||||
const canUseLandScale = computed(() => selectedMajorDictItem.value?.hasArea === true)
|
||||
const consultSupportsScale = computed(() => selectedConsultDictItem.value?.scale === 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 hasInvestValue = investScale.value.trim() !== ''
|
||||
const hasLandValue = landScale.value.trim() !== ''
|
||||
@ -146,7 +218,7 @@ const benchmarkSplit = computed(() => {
|
||||
})
|
||||
const formulaText = computed(() => {
|
||||
const split = benchmarkSplit.value
|
||||
if (!split) return '请先选择咨询类别、工程专业,并输入对应规模'
|
||||
if (!split) return '请先选择工程行业、咨询类别、工程专业,并输入对应规模'
|
||||
const parts = [split.basicFormula, split.optionalFormula].filter(Boolean)
|
||||
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 () => {
|
||||
await Promise.all([loadQuickIndustry(), loadFactorDefaults()])
|
||||
})
|
||||
|
||||
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>
|
||||
|
||||
@ -237,17 +375,56 @@ onActivated(() => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<Transition name="quick-calc-hint">
|
||||
<div v-if="showSelectionHint" class="quick-calc-hint-banner">
|
||||
<div class="quick-calc-hint-banner__content">
|
||||
<div class="quick-calc-hint-banner__title">先选咨询类别</div>
|
||||
<div class="quick-calc-hint-banner__text">再从通用专业或工程专业中选择一项继续计算。</div>
|
||||
</div>
|
||||
<button type="button" class="quick-calc-hint-banner__action" @click="showSelectionHint = false">
|
||||
<Check class="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</Transition>
|
||||
<div class="quick-calc-toolbar">
|
||||
<label class="quick-calc-toolbar__field">
|
||||
<span class="quick-calc-field__label">工程行业</span>
|
||||
<SelectRoot v-model="projectIndustry">
|
||||
<SelectTrigger class="quick-calc-toolbar__trigger">
|
||||
<SelectValue placeholder="请选择工程行业" />
|
||||
<SelectIcon as-child>
|
||||
<ChevronDown class="h-4 w-4 text-[var(--qc-muted)]" />
|
||||
</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-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">
|
||||
<article
|
||||
@ -344,9 +521,9 @@ onActivated(() => {
|
||||
<input
|
||||
v-model="investScale"
|
||||
class="quick-calc-field__input"
|
||||
:class="{ 'is-disabled': selectedMajor !== null && !canUseInvestScale }"
|
||||
:disabled="selectedMajor !== null && !canUseInvestScale"
|
||||
:placeholder="selectedMajor !== null && !canUseInvestScale ? '当前专业不适用' : '请输入'"
|
||||
:class="{ 'is-disabled': !canUseInvestScale }"
|
||||
:disabled="!canUseInvestScale"
|
||||
:placeholder="investScalePlaceholder"
|
||||
>
|
||||
</label>
|
||||
|
||||
@ -355,9 +532,9 @@ onActivated(() => {
|
||||
<input
|
||||
v-model="landScale"
|
||||
class="quick-calc-field__input"
|
||||
:class="{ 'is-disabled': selectedMajor !== null && !canUseLandScale }"
|
||||
:disabled="selectedMajor !== null && !canUseLandScale"
|
||||
:placeholder="selectedMajor !== null && !canUseLandScale ? '当前专业不适用' : '请输入'"
|
||||
:class="{ 'is-disabled': !canUseLandScale }"
|
||||
:disabled="!canUseLandScale"
|
||||
:placeholder="landScalePlaceholder"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
@ -399,7 +576,7 @@ onActivated(() => {
|
||||
<label class="quick-calc-field">
|
||||
<span class="quick-calc-field__label">工程专业系数</span>
|
||||
<div class="quick-calc-field__readonly">
|
||||
{{ engineeringMajorFactor == null ? '1.000' : engineeringMajorFactor.toFixed(3) }}
|
||||
{{ engineeringMajorFactor == null ? '--' : engineeringMajorFactor.toFixed(3) }}
|
||||
</div>
|
||||
</label>
|
||||
|
||||
@ -472,6 +649,58 @@ onActivated(() => {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
|
||||
@ -4,6 +4,8 @@ import type { ComponentPublicInstance } from 'vue'
|
||||
import draggable from 'vuedraggable'
|
||||
import { useTabStore } from '@/pinia/tab'
|
||||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||||
import { useZxFwPricingKeysStore } from '@/pinia/zxFwPricingKeys'
|
||||
import { useZxFwPricingHtFeeStore } from '@/pinia/zxFwPricingHtFee'
|
||||
import { useKvStore } from '@/pinia/kv'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
@ -27,7 +29,13 @@ import {
|
||||
ToastViewport
|
||||
} from 'reka-ui'
|
||||
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 { getBenchmarkBudgetSplitByScale, getScaleBudgetFeeSplit } from '@/lib/pricingScaleFee'
|
||||
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 PINIA_PERSIST_DB_NAME = 'DB'
|
||||
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 userGuideSteps: UserGuideStep[] = [
|
||||
{
|
||||
@ -491,6 +499,8 @@ const componentMap: Record<string, any> = {
|
||||
|
||||
const tabStore = useTabStore()
|
||||
const zxFwPricingStore = useZxFwPricingStore()
|
||||
const zxFwPricingKeysStore = useZxFwPricingKeysStore()
|
||||
const zxFwPricingHtFeeStore = useZxFwPricingHtFeeStore()
|
||||
const kvStore = useKvStore()
|
||||
|
||||
|
||||
@ -1859,20 +1869,24 @@ const triggerImport = () => {
|
||||
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 input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
if (!file) return
|
||||
|
||||
try {
|
||||
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
|
||||
await prepareImportPayloadFromFile(file)
|
||||
} catch (error) {
|
||||
console.error('import failed:', error)
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
@ -1922,6 +1936,16 @@ const confirmImportOverride = async () => {
|
||||
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')
|
||||
if (kvState) {
|
||||
kvStore.$patch(kvState as any)
|
||||
@ -1930,6 +1954,8 @@ const confirmImportOverride = async () => {
|
||||
await Promise.all([
|
||||
tabStore.$persistNow?.(),
|
||||
zxFwPricingStore.$persistNow?.(),
|
||||
zxFwPricingKeysStore.$persistNow?.(),
|
||||
zxFwPricingHtFeeStore.$persistNow?.(),
|
||||
kvStore.$persistNow?.()
|
||||
])
|
||||
dataMenuOpen.value = false
|
||||
@ -2025,6 +2051,7 @@ onMounted(() => {
|
||||
window.addEventListener('mousedown', handleGlobalMouseDown)
|
||||
window.addEventListener('keydown', handleGlobalKeyDown)
|
||||
window.addEventListener('resize', scheduleUpdateTabTitleOverflow)
|
||||
const pendingHomeImportFile = consumePendingHomeImportFile()
|
||||
void nextTick(() => {
|
||||
bindTabStripScroll()
|
||||
ensureActiveTabVisible()
|
||||
@ -2032,6 +2059,12 @@ onMounted(() => {
|
||||
scheduleUpdateTabTitleOverflow()
|
||||
scheduleRestoreTabInnerScrollTop(tabStore.activeTabId)
|
||||
})
|
||||
if (pendingHomeImportFile) {
|
||||
void prepareImportPayloadFromFile(pendingHomeImportFile).catch(error => {
|
||||
console.error('home import failed:', error)
|
||||
window.alert('导入失败:文件无效、已损坏或被修改。')
|
||||
})
|
||||
}
|
||||
void (async () => {
|
||||
if (await shouldAutoOpenGuide()) {
|
||||
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_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_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_MAJOR_FACTOR_KEY = 'quick-xm-major-factor-v1'
|
||||
|
||||
let pendingHomeImportFile: File | null = null
|
||||
|
||||
export interface QuickContractMeta {
|
||||
id: 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'
|
||||
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user