联动调整

This commit is contained in:
wintsa 2026-03-23 17:09:32 +08:00
parent a280dfb975
commit e4c6203a98
8 changed files with 712 additions and 159 deletions

View File

@ -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 () => {

View File

@ -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 || []

View File

@ -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)
}

View File

@ -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>

View File

@ -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;

View File

@ -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)

View File

@ -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
}

View 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