414 lines
14 KiB
Vue
414 lines
14 KiB
Vue
<script setup lang="ts">
|
||
import { computed, onActivated, onMounted, ref, watch } from 'vue'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||
import { Button } from '@/components/ui/button'
|
||
import { useKvStore } from '@/pinia/kv'
|
||
import { useTabStore } from '@/pinia/tab'
|
||
import { useZxFwPricingStore } from '@/pinia/zxFwPricing'
|
||
import { ArrowRight, Calculator, PencilLine } from 'lucide-vue-next'
|
||
import { industryTypeList } from '@/sql'
|
||
import { formatThousands } from '@/lib/numberFormat'
|
||
import { roundTo } from '@/lib/decimal'
|
||
import { initializeProjectFactorStates } from '@/lib/projectWorkspace'
|
||
import {
|
||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||
QUICK_CONTRACT_FALLBACK_NAME,
|
||
QUICK_CONTRACT_ID,
|
||
QUICK_CONTRACT_META_KEY,
|
||
QUICK_MAJOR_FACTOR_KEY,
|
||
QUICK_PROJECT_INFO_KEY,
|
||
QUICK_PROJECT_SCALE_KEY,
|
||
createDefaultQuickContractMeta,
|
||
writeWorkspaceMode
|
||
} from '@/lib/workspace'
|
||
|
||
interface QuickProjectInfoState {
|
||
projectIndustry?: string
|
||
projectName?: string
|
||
preparedBy?: string
|
||
reviewedBy?: string
|
||
preparedCompany?: string
|
||
preparedDate?: string
|
||
}
|
||
|
||
interface QuickContractMetaState {
|
||
id?: string
|
||
name?: string
|
||
updatedAt?: string
|
||
}
|
||
|
||
interface HtFeeMainRowLike {
|
||
id?: unknown
|
||
}
|
||
|
||
interface RateMethodStateLike {
|
||
budgetFee?: unknown
|
||
}
|
||
|
||
interface HourlyMethodRowLike {
|
||
serviceBudget?: unknown
|
||
adoptedBudgetUnitPrice?: unknown
|
||
personnelCount?: unknown
|
||
workdayCount?: unknown
|
||
}
|
||
|
||
interface HourlyMethodStateLike {
|
||
detailRows?: HourlyMethodRowLike[]
|
||
}
|
||
|
||
interface QuantityMethodRowLike {
|
||
id?: unknown
|
||
budgetFee?: unknown
|
||
quantity?: unknown
|
||
unitPrice?: unknown
|
||
}
|
||
|
||
interface QuantityMethodStateLike {
|
||
detailRows?: QuantityMethodRowLike[]
|
||
}
|
||
|
||
const kvStore = useKvStore()
|
||
const tabStore = useTabStore()
|
||
const zxFwPricingStore = useZxFwPricingStore()
|
||
|
||
const contractName = ref(QUICK_CONTRACT_FALLBACK_NAME)
|
||
const projectIndustry = ref(String(industryTypeList[0]?.id || ''))
|
||
const quickBudget = ref<number | null>(null)
|
||
const savingIndustry = ref(false)
|
||
let budgetRefreshTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
const availableIndustries = computed(() =>
|
||
industryTypeList.map(item => ({
|
||
id: String(item.id),
|
||
name: item.name
|
||
}))
|
||
)
|
||
|
||
const formatBudgetAmount = (value: number | null | undefined) =>
|
||
typeof value === 'number' && Number.isFinite(value) ? `${formatThousands(value, 2)} 元` : '--'
|
||
|
||
const toFiniteNumber = (value: unknown): number | null => {
|
||
if (value == null || value === '') return null
|
||
const num = Number(value)
|
||
return Number.isFinite(num) ? num : null
|
||
}
|
||
|
||
const sumHourlyMethodFee = (state: HourlyMethodStateLike | null): number | null => {
|
||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||
if (rows.length === 0) return null
|
||
let hasValid = false
|
||
let total = 0
|
||
for (const row of rows) {
|
||
const serviceBudget = toFiniteNumber(row?.serviceBudget)
|
||
if (serviceBudget != null) {
|
||
total += serviceBudget
|
||
hasValid = true
|
||
continue
|
||
}
|
||
const adopted = toFiniteNumber(row?.adoptedBudgetUnitPrice)
|
||
const personnel = toFiniteNumber(row?.personnelCount)
|
||
const workday = toFiniteNumber(row?.workdayCount)
|
||
if (adopted == null || personnel == null || workday == null) continue
|
||
total += adopted * personnel * workday
|
||
hasValid = true
|
||
}
|
||
return hasValid ? roundTo(total, 2) : null
|
||
}
|
||
|
||
const sumQuantityMethodFee = (state: QuantityMethodStateLike | null): number | null => {
|
||
const rows = Array.isArray(state?.detailRows) ? state.detailRows : []
|
||
if (rows.length === 0) return null
|
||
const subtotalRow = rows.find(row => String(row?.id || '') === 'fee-subtotal-fixed')
|
||
const subtotal = toFiniteNumber(subtotalRow?.budgetFee)
|
||
if (subtotal != null) return roundTo(subtotal, 2)
|
||
|
||
let hasValid = false
|
||
let total = 0
|
||
for (const row of rows) {
|
||
if (String(row?.id || '') === 'fee-subtotal-fixed') continue
|
||
const budget = toFiniteNumber(row?.budgetFee)
|
||
if (budget != null) {
|
||
total += budget
|
||
hasValid = true
|
||
continue
|
||
}
|
||
const quantity = toFiniteNumber(row?.quantity)
|
||
const unitPrice = toFiniteNumber(row?.unitPrice)
|
||
if (quantity == null || unitPrice == null) continue
|
||
total += quantity * unitPrice
|
||
hasValid = true
|
||
}
|
||
return hasValid ? roundTo(total, 2) : null
|
||
}
|
||
|
||
const loadHtMethodTotalByRow = async (mainStorageKey: string, rowId: string) => {
|
||
const [rateState, hourlyState, quantityState] = await Promise.all([
|
||
zxFwPricingStore.loadHtFeeMethodState<RateMethodStateLike>(mainStorageKey, rowId, 'rate-fee'),
|
||
zxFwPricingStore.loadHtFeeMethodState<HourlyMethodStateLike>(mainStorageKey, rowId, 'hourly-fee'),
|
||
zxFwPricingStore.loadHtFeeMethodState<QuantityMethodStateLike>(mainStorageKey, rowId, 'quantity-unit-price-fee')
|
||
])
|
||
const parts = [
|
||
toFiniteNumber(rateState?.budgetFee),
|
||
sumHourlyMethodFee(hourlyState),
|
||
sumQuantityMethodFee(quantityState)
|
||
]
|
||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||
if (validParts.length === 0) return null
|
||
return roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||
}
|
||
|
||
const loadHtMainTotalFee = async (mainStorageKey: string) => {
|
||
const mainState = await zxFwPricingStore.loadHtFeeMainState<HtFeeMainRowLike>(mainStorageKey)
|
||
const rows = Array.isArray(mainState?.detailRows) ? mainState.detailRows : []
|
||
const rowIds = rows.map(row => String(row?.id || '').trim()).filter(Boolean)
|
||
if (rowIds.length === 0) return null
|
||
const rowTotals = await Promise.all(rowIds.map(rowId => loadHtMethodTotalByRow(mainStorageKey, rowId)))
|
||
const validTotals = rowTotals.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||
if (validTotals.length === 0) return null
|
||
return roundTo(validTotals.reduce((sum, value) => sum + value, 0), 2)
|
||
}
|
||
|
||
const refreshQuickBudget = async () => {
|
||
await zxFwPricingStore.loadContract(QUICK_CONTRACT_ID)
|
||
const serviceFee = zxFwPricingStore.getBaseSubtotal(QUICK_CONTRACT_ID)
|
||
const [additionalFee, reserveFee] = await Promise.all([
|
||
loadHtMainTotalFee(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work`),
|
||
loadHtMainTotalFee(`htExtraFee-${QUICK_CONTRACT_ID}-reserve`)
|
||
])
|
||
const parts = [serviceFee, additionalFee, reserveFee]
|
||
const validParts = parts.filter((item): item is number => typeof item === 'number' && Number.isFinite(item))
|
||
quickBudget.value = validParts.length === 0 ? null : roundTo(validParts.reduce((sum, value) => sum + value, 0), 2)
|
||
}
|
||
|
||
const scheduleRefreshQuickBudget = () => {
|
||
if (budgetRefreshTimer) clearTimeout(budgetRefreshTimer)
|
||
budgetRefreshTimer = setTimeout(() => {
|
||
void refreshQuickBudget()
|
||
}, 80)
|
||
}
|
||
|
||
const normalizeQuickContractMeta = (value: QuickContractMetaState | null) => ({
|
||
id: typeof value?.id === 'string' && value.id.trim() ? value.id.trim() : QUICK_CONTRACT_ID,
|
||
name: typeof value?.name === 'string' && value.name.trim() ? value.name.trim() : QUICK_CONTRACT_FALLBACK_NAME,
|
||
updatedAt:
|
||
typeof value?.updatedAt === 'string' && value.updatedAt.trim()
|
||
? value.updatedAt
|
||
: new Date().toISOString()
|
||
})
|
||
|
||
const ensureQuickWorkspaceReady = async () => {
|
||
const [savedInfo, savedMeta] = await Promise.all([
|
||
kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY),
|
||
kvStore.getItem<QuickContractMetaState>(QUICK_CONTRACT_META_KEY)
|
||
])
|
||
|
||
const defaultIndustry = String(industryTypeList[0]?.id || '')
|
||
const nextIndustry =
|
||
typeof savedInfo?.projectIndustry === 'string' && savedInfo.projectIndustry.trim()
|
||
? savedInfo.projectIndustry.trim()
|
||
: defaultIndustry
|
||
|
||
projectIndustry.value = nextIndustry
|
||
contractName.value = normalizeQuickContractMeta(savedMeta).name
|
||
|
||
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||
projectIndustry: nextIndustry,
|
||
projectName: '快速计算'
|
||
})
|
||
|
||
const consultState = await kvStore.getItem(QUICK_CONSULT_CATEGORY_FACTOR_KEY)
|
||
const majorState = await kvStore.getItem(QUICK_MAJOR_FACTOR_KEY)
|
||
if (!consultState || !majorState) {
|
||
await initializeProjectFactorStates(
|
||
kvStore,
|
||
nextIndustry,
|
||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||
QUICK_MAJOR_FACTOR_KEY
|
||
)
|
||
}
|
||
|
||
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||
...createDefaultQuickContractMeta(),
|
||
...normalizeQuickContractMeta(savedMeta)
|
||
})
|
||
}
|
||
|
||
const persistQuickContractMeta = async () => {
|
||
await kvStore.setItem(QUICK_CONTRACT_META_KEY, {
|
||
id: QUICK_CONTRACT_ID,
|
||
name: contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME,
|
||
updatedAt: new Date().toISOString()
|
||
})
|
||
}
|
||
|
||
const persistQuickIndustry = async (industry: string) => {
|
||
if (!industry) return
|
||
savingIndustry.value = true
|
||
try {
|
||
const current = await kvStore.getItem<QuickProjectInfoState>(QUICK_PROJECT_INFO_KEY)
|
||
await kvStore.setItem(QUICK_PROJECT_INFO_KEY, {
|
||
...current,
|
||
projectIndustry: industry,
|
||
projectName: '快速计算'
|
||
})
|
||
await initializeProjectFactorStates(
|
||
kvStore,
|
||
industry,
|
||
QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||
QUICK_MAJOR_FACTOR_KEY
|
||
)
|
||
} finally {
|
||
savingIndustry.value = false
|
||
}
|
||
}
|
||
|
||
const openQuickContract = () => {
|
||
writeWorkspaceMode('quick')
|
||
tabStore.openTab({
|
||
id: `contract-${QUICK_CONTRACT_ID}`,
|
||
title: `快速计算-${contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME}`,
|
||
componentName: 'ContractDetailView',
|
||
props: {
|
||
contractId: QUICK_CONTRACT_ID,
|
||
contractName: contractName.value.trim() || QUICK_CONTRACT_FALLBACK_NAME,
|
||
projectInfoKey: QUICK_PROJECT_INFO_KEY,
|
||
projectScaleKey: QUICK_PROJECT_SCALE_KEY,
|
||
projectConsultCategoryFactorKey: QUICK_CONSULT_CATEGORY_FACTOR_KEY,
|
||
projectMajorFactorKey: QUICK_MAJOR_FACTOR_KEY
|
||
}
|
||
})
|
||
}
|
||
|
||
watch(
|
||
() => contractName.value,
|
||
() => {
|
||
void persistQuickContractMeta()
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => projectIndustry.value,
|
||
nextIndustry => {
|
||
if (!nextIndustry) return
|
||
void persistQuickIndustry(nextIndustry)
|
||
}
|
||
)
|
||
|
||
watch(
|
||
() => [
|
||
zxFwPricingStore.contractVersions[QUICK_CONTRACT_ID] || 0,
|
||
zxFwPricingStore.getKeyVersion(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work`),
|
||
zxFwPricingStore.getKeyVersion(`htExtraFee-${QUICK_CONTRACT_ID}-reserve`),
|
||
Object.entries(zxFwPricingStore.keyVersions)
|
||
.filter(([key]) =>
|
||
key.startsWith(`htExtraFee-${QUICK_CONTRACT_ID}-additional-work-`) ||
|
||
key.startsWith(`htExtraFee-${QUICK_CONTRACT_ID}-reserve-`)
|
||
)
|
||
.map(([key, version]) => `${key}:${version}`)
|
||
.join('|')
|
||
],
|
||
scheduleRefreshQuickBudget
|
||
)
|
||
|
||
onMounted(async () => {
|
||
writeWorkspaceMode('quick')
|
||
await ensureQuickWorkspaceReady()
|
||
await refreshQuickBudget()
|
||
})
|
||
|
||
onActivated(() => {
|
||
writeWorkspaceMode('quick')
|
||
scheduleRefreshQuickBudget()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<div class="mx-auto flex h-full w-full max-w-6xl flex-col gap-6">
|
||
<div class="grid gap-4 lg:grid-cols-[minmax(0,1.25fr)_minmax(320px,0.75fr)]">
|
||
<Card class="border-border/70">
|
||
<CardHeader>
|
||
<CardTitle class="flex items-center gap-2 text-2xl">
|
||
<Calculator class="h-5 w-5" />
|
||
快速计算
|
||
</CardTitle>
|
||
<CardDescription>
|
||
保留一个默认合同卡片,不再经过项目卡片入口,直接进入单合同预算费用计算。
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent class="grid gap-4 md:grid-cols-2">
|
||
<label class="space-y-2">
|
||
<span class="text-sm font-medium text-foreground">工程行业</span>
|
||
<select
|
||
v-model="projectIndustry"
|
||
class="h-11 w-full rounded-md border bg-background px-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||
>
|
||
<option v-for="item in availableIndustries" :key="item.id" :value="item.id">
|
||
{{ item.name }}
|
||
</option>
|
||
</select>
|
||
</label>
|
||
<label class="space-y-2">
|
||
<span class="text-sm font-medium text-foreground">合同名称</span>
|
||
<div class="relative">
|
||
<PencilLine class="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||
<input
|
||
v-model="contractName"
|
||
type="text"
|
||
maxlength="40"
|
||
class="h-11 w-full rounded-md border bg-background pl-9 pr-3 text-sm outline-none transition focus-visible:ring-2 focus-visible:ring-ring"
|
||
placeholder="请输入合同名称"
|
||
/>
|
||
</div>
|
||
</label>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card class="border-border/70 bg-muted/25">
|
||
<CardHeader class="pb-3">
|
||
<CardTitle class="text-base">当前状态</CardTitle>
|
||
</CardHeader>
|
||
<CardContent class="grid gap-2 text-sm text-muted-foreground">
|
||
<div>模式:单合同快速计算</div>
|
||
<div>合同ID:{{ QUICK_CONTRACT_ID }}</div>
|
||
<div>行业切换会同步重建快速计算专用系数基线</div>
|
||
<div>{{ savingIndustry ? '正在切换行业并刷新系数...' : '行业与合同名称已自动保存' }}</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
<Card
|
||
class="group cursor-pointer border-border/70 transition-colors hover:border-primary"
|
||
@click="openQuickContract"
|
||
>
|
||
<CardHeader class="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||
<div class="space-y-2">
|
||
<CardTitle class="text-xl">
|
||
{{ contractName.trim() || QUICK_CONTRACT_FALLBACK_NAME }}
|
||
</CardTitle>
|
||
<CardDescription>默认单合同卡片,点击后进入预算费用计算详情。</CardDescription>
|
||
</div>
|
||
<Button class="shrink-0 md:self-center" @click.stop="openQuickContract">
|
||
进入计算
|
||
<ArrowRight class="ml-1 h-4 w-4" />
|
||
</Button>
|
||
</CardHeader>
|
||
<CardContent class="grid gap-4 border-t pt-5 text-sm text-muted-foreground md:grid-cols-3">
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">合同ID</div>
|
||
<div class="mt-2 break-all text-foreground">{{ QUICK_CONTRACT_ID }}</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">预算费用</div>
|
||
<div class="mt-2 text-foreground">{{ formatBudgetAmount(quickBudget) }}</div>
|
||
</div>
|
||
<div>
|
||
<div class="text-xs uppercase tracking-[0.24em] text-muted-foreground/80">行业</div>
|
||
<div class="mt-2 text-foreground">
|
||
{{ availableIndustries.find(item => item.id === projectIndustry)?.name || '--' }}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</template>
|