Files
tjt_czjs_ui/src/views/tjt/output-split/index.vue
2026-04-29 17:12:41 +08:00

742 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<ContentWrap>
<el-form
ref="queryFormRef"
:inline="true"
:model="queryParams"
class="-mb-15px"
label-width="88px"
>
<el-form-item label="工程名称" prop="projectName">
<el-input
v-model="queryParams.projectName"
class="!w-240px"
clearable
placeholder="请输入工程名称"
@keyup.enter="handleQuery"
/>
</el-form-item>
<el-form-item label="开始年度" prop="projectStartYear">
<el-date-picker
v-model="queryProjectStartYearValue"
class="!w-180px"
clearable
placeholder="请选择年度"
type="year"
value-format="YYYY"
/>
</el-form-item>
<el-form-item>
<el-button @click="handleQuery">
<Icon class="mr-5px" icon="ep:search" />
搜索
</el-button>
<el-button @click="resetQuery">
<Icon class="mr-5px" icon="ep:refresh" />
重置
</el-button>
</el-form-item>
</el-form>
</ContentWrap>
<ContentWrap>
<el-table
ref="projectTableRef"
v-loading="loading"
:data="projectList"
highlight-current-row
@current-change="handleCurrentProjectChange"
>
<el-table-column align="center" label="项目ID" prop="id" width="88" />
<el-table-column align="center" label="工程名称" min-width="220" prop="projectName" />
<el-table-column align="center" label="项目负责人" min-width="180">
<template #default="scope">
{{ getProjectLeadText(scope.row.projectManagerName, scope.row.engineeringPrincipalName) }}
</template>
</el-table-column>
<el-table-column align="center" label="开始年度" prop="projectStartYear" width="120" />
</el-table>
<Pagination
v-model:limit="queryParams.pageSize"
v-model:page="queryParams.pageNo"
:total="total"
@pagination="getProjectList"
/>
</ContentWrap>
<el-row :gutter="16">
<el-col :span="8">
<ContentWrap>
<div class="mb-12px text-14px font-600">
{{ currentProject?.projectName || '合约规划列表' }}
</div>
<el-table
ref="planningTableRef"
v-loading="planningLoading"
:data="planningList"
highlight-current-row
@current-change="handleCurrentPlanningChange"
>
<el-table-column align="center" label="规划内容" min-width="180" prop="planningContent" />
<el-table-column align="center" label="归属类型" min-width="110">
<template #default="scope">
{{ getOwnershipTypeLabel(scope.row.ownershipType) }}
</template>
</el-table-column>
<el-table-column align="center" label="开始年度" prop="planningStartYear" width="100" />
<el-table-column align="center" label="考核产值(元)" width="120">
<template #default="scope">
{{ formatAmountText(scope.row.assessmentOutputValue) }}
</template>
</el-table-column>
</el-table>
</ContentWrap>
</el-col>
<el-col :span="16">
<ContentWrap v-if="currentPlanning && formData">
<div class="mb-16px flex items-center justify-between gap-16px">
<div>
<div class="text-16px font-600">{{ currentPlanning.planningContent }}</div>
<div class="mt-4px text-13px text-[var(--el-text-color-secondary)]">
年度{{ formData.year || '-' }}考核产值{{ formatAmountText(formData.assessmentOutputValue) }}
</div>
</div>
<div class="flex items-center gap-12px">
<el-button
v-hasPermi="['tjt:output-split:update']"
plain
type="primary"
@click="openEditDialog"
>
<Icon class="mr-5px" icon="ep:edit" />
编辑比例
</el-button>
</div>
</div>
<el-descriptions :column="2" border title="基础信息">
<el-descriptions-item label="项目名称">{{ formData.projectName || '-' }}</el-descriptions-item>
<el-descriptions-item label="规划内容">{{ formData.planningContent || '-' }}</el-descriptions-item>
<el-descriptions-item label="项目负责人">
{{ getProjectLeadText(formData.projectManagerName, formData.engineeringLeaderName) }}
</el-descriptions-item>
</el-descriptions>
<el-divider content-position="left">项目层结果</el-divider>
<el-table :data="projectResultRows" border>
<el-table-column align="center" label="类别" min-width="160" prop="label" />
<el-table-column align="center" label="比例" min-width="120">
<template #default="scope">
{{ scope.row.percentText }}
</template>
</el-table-column>
<el-table-column align="center" label="金额(元)" min-width="140">
<template #default="scope">
{{ formatAmountText(scope.row.amount) }}
</template>
</el-table-column>
</el-table>
<el-divider content-position="left">专业层结果</el-divider>
<el-table :data="specialtyResultRows" border>
<el-table-column align="center" label="专业" min-width="140" prop="label" />
<el-table-column align="center" label="比例" min-width="120">
<template #default="scope">
{{ scope.row.percentText }}
</template>
</el-table-column>
<el-table-column align="center" label="金额(元)" min-width="140">
<template #default="scope">
{{ formatAmountText(scope.row.amount) }}
</template>
</el-table-column>
</el-table>
<el-divider content-position="left">年度分配信息</el-divider>
<div class="mb-12px">
<el-radio-group v-model="selectedAnnualCategory" size="small">
<el-radio-button
v-for="item in annualCategoryOptions"
:key="item.value"
:label="item.value"
>
{{ item.label }}
</el-radio-button>
</el-radio-group>
</div>
<el-row :gutter="16" class="mb-16px">
<el-col v-for="item in annualSummaryCards" :key="item.label" :span="8">
<div class="rounded-8px bg-[var(--el-fill-color-light)] px-16px py-12px">
<div class="text-12px text-[var(--el-text-color-secondary)]">{{ item.label }}</div>
<div class="mt-6px text-18px font-600">{{ formatAmountText(item.amount) }}</div>
</div>
</el-col>
</el-row>
<el-table v-loading="quarterLoading" :data="annualDistributionRows" border>
<el-table-column align="center" label="分配年度" min-width="120" prop="distributionYear" />
<el-table-column
v-for="quarter in QUARTER_OPTIONS"
:key="String(quarter.value)"
align="center"
:label="quarter.label"
min-width="150"
>
<template #default="scope">
{{ formatAmountText(scope.row.quarterAmounts[Number(quarter.value)]) }}
</template>
</el-table-column>
<el-table-column align="center" label="年度合计(元)" min-width="160">
<template #default="scope">
{{ formatAmountText(scope.row.yearTotal) }}
</template>
</el-table-column>
</el-table>
</ContentWrap>
<ContentWrap v-else>
<el-empty description="请选择合约规划后查看分配结果" />
</ContentWrap>
</el-col>
</el-row>
<Dialog v-model="dialogVisible" title="编辑专业及项目总分配比例" width="920">
<template v-if="editForm">
<el-divider content-position="left">项目层比例编辑</el-divider>
<el-row :gutter="16">
<el-col v-for="item in editProjectRatioItems" :key="item.key" :span="12">
<div class="ratio-card">
<div class="ratio-title">{{ item.label }}</div>
<el-input-number
:model-value="item.percent"
:disabled="item.key === 'officeRatio'"
:min="0"
:precision="2"
:step="0.1"
class="!w-1/1"
controls-position="right"
@update:model-value="(value) => updateProjectRatio(item.key, value)"
/>
<div class="ratio-amount">金额:{{ formatAmountText(item.amount) }} 元</div>
</div>
</el-col>
</el-row>
<div class="mt-8px text-12px" :class="projectRatioTotalClass">
项目层比例合计:{{ projectRatioTotalText }}
</div>
<el-divider content-position="left">专业层比例编辑</el-divider>
<el-row :gutter="16">
<el-col v-for="item in editSpecialtyRatioItems" :key="item.key" :span="8" class="mb-12px">
<div class="ratio-card">
<div class="ratio-title">{{ item.label }}</div>
<el-input-number
:model-value="item.percent"
:disabled="item.key === 'archRatio'"
:min="0"
:precision="2"
:step="0.1"
class="!w-1/1"
controls-position="right"
@update:model-value="(value) => updateSpecialtyRatio(item.key, value)"
/>
<div class="ratio-amount">金额:{{ formatAmountText(item.amount) }} 元</div>
</div>
</el-col>
</el-row>
<div class="mt-8px text-12px" :class="specialtyRatioTotalClass">
专业层比例合计:{{ specialtyRatioTotalText }}
</div>
</template>
<template #footer>
<el-button :loading="saveLoading" type="primary" @click="handleSave">保存</el-button>
<el-button @click="dialogVisible = false">取消</el-button>
</template>
</Dialog>
</template>
<script lang="ts" setup>
import * as ProjectApi from '@/api/tjt/project'
import * as PlanningApi from '@/api/tjt/planning'
import * as PlanningQuarterApi from '@/api/tjt/planningQuarter'
import * as OutputSplitApi from '@/api/tjt/outputSplit'
import {
formatAmountText,
fromPercentValue,
getOwnershipTypeLabel,
OUTPUT_SPLIT_SPECIALTY_OPTIONS,
QUARTER_OPTIONS,
toPercentValue
} from '@/views/tjt/shared/planning'
defineOptions({ name: 'TjtOutputSplit' })
type AnnualCategoryKey =
| 'project_lead'
| 'arch'
| 'decor'
| 'struct'
| 'water'
| 'elec'
| 'hvac'
| 'digital'
interface QuarterYearRow {
distributionYear: number
quarters: PlanningQuarterApi.ProjectPlanningQuarterVO[]
}
const annualCategoryOptions: { label: string; value: AnnualCategoryKey }[] = [
{ label: '项目经理/项目负责人', value: 'project_lead' },
{ label: '建筑专业', value: 'arch' },
{ label: '装修专业', value: 'decor' },
{ label: '结构专业', value: 'struct' },
{ label: '水专业', value: 'water' },
{ label: '电气专业', value: 'elec' },
{ label: '暖通专业', value: 'hvac' },
{ label: '数字化设计专业', value: 'digital' }
]
const message = useMessage()
const loading = ref(false)
const planningLoading = ref(false)
const quarterLoading = ref(false)
const saveLoading = ref(false)
const total = ref(0)
const projectList = ref<ProjectApi.ProjectVO[]>([])
const planningList = ref<PlanningApi.ProjectPlanningVO[]>([])
const currentProject = ref<ProjectApi.ProjectVO>()
const currentPlanning = ref<PlanningApi.ProjectPlanningVO>()
const formData = ref<OutputSplitApi.ProjectOutputSplitVO>()
const editForm = ref<OutputSplitApi.ProjectOutputSplitVO>()
const quarterRows = ref<QuarterYearRow[]>([])
const selectedAnnualCategory = ref<AnnualCategoryKey>('project_lead')
const dialogVisible = ref(false)
const queryFormRef = ref()
const projectTableRef = ref()
const planningTableRef = ref()
const queryParams = reactive<ProjectApi.ProjectPageReqVO>({
pageNo: 1,
pageSize: 10,
projectName: undefined,
projectStartYear: undefined
})
const queryProjectStartYearValue = computed({
get: () => (queryParams.projectStartYear ? String(queryParams.projectStartYear) : undefined),
set: (value?: string) => {
queryParams.projectStartYear = value ? Number(value) : undefined
}
})
const toNumeric = (value?: number | string | null) => {
const numericValue = Number(value ?? 0)
return Number.isNaN(numericValue) ? 0 : numericValue
}
const multiplyAmount = (...values: Array<number | string | null | undefined>) => {
let result = 1
values.forEach((value) => {
result *= toNumeric(value)
})
return Number(result.toFixed(2))
}
const formatRatioText = (value?: number | string | null) => `${(toPercentValue(value) ?? 0).toFixed(2)}%`
const getRatioValue = (model: OutputSplitApi.ProjectOutputSplitVO, key: string) =>
(model as unknown as Record<string, number | undefined>)[key]
const getProjectLeadText = (projectManagerName?: string, engineeringLeaderName?: string) =>
[projectManagerName, engineeringLeaderName].filter(Boolean).join(' / ') || '-'
const buildProjectRows = (model?: OutputSplitApi.ProjectOutputSplitVO) => {
if (!model) {
return []
}
return [
{
key: 'projectLeadRatio',
label: '项目经理/项目负责人',
percentText: formatRatioText(model.projectLeadRatio),
percent: toPercentValue(model.projectLeadRatio),
amount: model.projectLeadAmount
},
{
key: 'officeRatio',
label: '专业所',
percentText: formatRatioText(model.officeRatio),
percent: toPercentValue(model.officeRatio),
amount: model.officeAmount
}
]
}
const buildSpecialtyRows = (model?: OutputSplitApi.ProjectOutputSplitVO) => {
if (!model) {
return []
}
const amountMap: Record<string, number | undefined> = {
arch: model.archAmount,
decor: model.decorAmount,
struct: model.structAmount,
water: model.waterAmount,
elec: model.elecAmount,
hvac: model.hvacAmount,
digital: model.digitalAmount
}
return OUTPUT_SPLIT_SPECIALTY_OPTIONS.map((item) => ({
key: `${item.value}Ratio`,
label: item.label,
percentText: formatRatioText(getRatioValue(model, `${item.value}Ratio`)),
percent: toPercentValue(getRatioValue(model, `${item.value}Ratio`)),
amount: amountMap[item.value]
}))
}
const projectResultRows = computed(() => buildProjectRows(formData.value))
const specialtyResultRows = computed(() => buildSpecialtyRows(formData.value))
const editProjectRatioItems = computed(() => buildProjectRows(editForm.value))
const editSpecialtyRatioItems = computed(() => buildSpecialtyRows(editForm.value))
const annualCategoryMeta = computed(() => {
const model = formData.value
const option = annualCategoryOptions.find((item) => item.value === selectedAnnualCategory.value)
if (!model || !option) {
return { label: '-', ratio: 0, totalAmount: 0 }
}
if (selectedAnnualCategory.value === 'project_lead') {
const ratio = Number(toNumeric(model.projectLeadRatio).toFixed(4))
return {
label: option.label,
ratio,
totalAmount: multiplyAmount(model.assessmentOutputValue, ratio)
}
}
const specialtyRatio = toNumeric(getRatioValue(model, `${selectedAnnualCategory.value}Ratio`))
const ratio = Number((toNumeric(model.officeRatio) * specialtyRatio).toFixed(4))
return {
label: option.label,
ratio,
totalAmount: multiplyAmount(model.assessmentOutputValue, ratio)
}
})
const annualDistributionRows = computed(() =>
quarterRows.value.map((row) => {
const quarterAmounts: Record<number, number> = {}
let yearTotal = 0
QUARTER_OPTIONS.forEach((quarter) => {
const quarterNo = Number(quarter.value)
const quarterAmount = multiplyAmount(
row.quarters.find((item) => item.quarterNo === quarterNo)?.distributionAmount,
annualCategoryMeta.value.ratio
)
quarterAmounts[quarterNo] = quarterAmount
yearTotal += quarterAmount
})
return {
distributionYear: row.distributionYear,
quarterAmounts,
yearTotal: Number(yearTotal.toFixed(2))
}
})
)
const annualSummaryCards = computed(() => [
{
label: '总分配',
amount: multiplyAmount(
currentPlanning.value?.assessmentOutputValue,
currentPlanning.value?.totalDistributionAmount,
annualCategoryMeta.value.ratio
)
},
{
label: '已分配',
amount: multiplyAmount(
currentPlanning.value?.assessmentOutputValue,
currentPlanning.value?.allocatedAmount,
annualCategoryMeta.value.ratio
)
},
{
label: '待分配',
amount: multiplyAmount(
currentPlanning.value?.assessmentOutputValue,
currentPlanning.value?.pendingAmount,
annualCategoryMeta.value.ratio
)
}
])
const projectRatioTotal = computed(() =>
editProjectRatioItems.value.reduce((sum, item) => sum + Number(item.percent || 0), 0)
)
const specialtyRatioTotal = computed(() =>
editSpecialtyRatioItems.value.reduce((sum, item) => sum + Number(item.percent || 0), 0)
)
const projectRatioTotalText = computed(() => `${projectRatioTotal.value.toFixed(2)}%`)
const specialtyRatioTotalText = computed(() => `${specialtyRatioTotal.value.toFixed(2)}%`)
const projectRatioTotalClass = computed(() =>
Math.abs(projectRatioTotal.value - 100) < 0.001
? 'text-[var(--el-color-success)]'
: 'text-[var(--el-color-danger)]'
)
const specialtyRatioTotalClass = computed(() =>
Math.abs(specialtyRatioTotal.value - 100) < 0.001
? 'text-[var(--el-color-success)]'
: 'text-[var(--el-color-danger)]'
)
const recalculateOfficeRatio = () => {
if (!editForm.value) {
return
}
editForm.value.officeRatio = Number(Math.max(0, 1 - Number(editForm.value.projectLeadRatio || 0)).toFixed(4))
}
const recalculateArchRatio = () => {
if (!editForm.value) {
return
}
const otherSpecialtyTotal =
Number(editForm.value.decorRatio || 0) +
Number(editForm.value.structRatio || 0) +
Number(editForm.value.waterRatio || 0) +
Number(editForm.value.elecRatio || 0) +
Number(editForm.value.hvacRatio || 0) +
Number(editForm.value.digitalRatio || 0)
editForm.value.archRatio = Number(Math.max(0, 1 - otherSpecialtyTotal).toFixed(4))
}
const buildQuarterRows = (
planning: PlanningApi.ProjectPlanningVO,
quarters: PlanningQuarterApi.ProjectPlanningQuarterVO[]
) => {
const yearSet = new Set<number>()
if (planning.planningStartYear) {
yearSet.add(planning.planningStartYear)
}
quarters.forEach((item) => yearSet.add(item.distributionYear))
if (yearSet.size === 0) {
yearSet.add(new Date().getFullYear())
}
return Array.from(yearSet)
.sort((a, b) => a - b)
.map((distributionYear) => ({
distributionYear,
quarters: QUARTER_OPTIONS.map((option) => {
const quarterNo = Number(option.value)
const match = quarters.find(
(item) =>
item.distributionYear === distributionYear && Number(item.quarterNo) === quarterNo
)
return (
match || {
planningId: planning.id!,
distributionYear,
quarterNo,
distributionRatio: undefined,
distributionAmount: 0
}
)
})
}))
}
const getProjectList = async () => {
loading.value = true
try {
const data = await ProjectApi.getProjectPage(queryParams)
projectList.value = data.list
total.value = data.total
if (!projectList.value.length) {
currentProject.value = undefined
planningList.value = []
currentPlanning.value = undefined
formData.value = undefined
quarterRows.value = []
return
}
const targetProjectId = currentProject.value?.id || projectList.value[0].id
const targetProject =
projectList.value.find((item) => item.id === targetProjectId) || projectList.value[0]
await nextTick()
projectTableRef.value?.setCurrentRow(targetProject)
} finally {
loading.value = false
}
}
const getPlanningList = async () => {
if (!currentProject.value?.id) {
planningList.value = []
currentPlanning.value = undefined
formData.value = undefined
quarterRows.value = []
return
}
planningLoading.value = true
try {
const list = await PlanningApi.getProjectPlanningListByProjectId(currentProject.value.id)
planningList.value = list
if (!planningList.value.length) {
currentPlanning.value = undefined
formData.value = undefined
quarterRows.value = []
return
}
const targetPlanningId = currentPlanning.value?.id || planningList.value[0].id
const targetPlanning =
planningList.value.find((item) => item.id === targetPlanningId) || planningList.value[0]
await nextTick()
planningTableRef.value?.setCurrentRow(targetPlanning)
} finally {
planningLoading.value = false
}
}
const loadPlanningRelatedData = async (planning: PlanningApi.ProjectPlanningVO) => {
if (!planning.id) {
return
}
quarterLoading.value = true
try {
const [planningDetail, outputSplit, quarterList] = await Promise.all([
PlanningApi.getProjectPlanning(planning.id),
OutputSplitApi.getProjectOutputSplitByPlanningId(planning.id),
PlanningQuarterApi.getProjectPlanningQuarterListByPlanningId(planning.id)
])
currentPlanning.value = planningDetail
formData.value = outputSplit
quarterRows.value = buildQuarterRows(planningDetail, quarterList)
} finally {
quarterLoading.value = false
}
}
const handleQuery = () => {
queryParams.pageNo = 1
getProjectList()
}
const resetQuery = () => {
queryFormRef.value?.resetFields()
handleQuery()
}
const handleCurrentProjectChange = async (row?: ProjectApi.ProjectVO) => {
currentProject.value = row || undefined
await getPlanningList()
}
const handleCurrentPlanningChange = async (row?: PlanningApi.ProjectPlanningVO) => {
currentPlanning.value = row || undefined
if (!row?.id) {
formData.value = undefined
quarterRows.value = []
return
}
await loadPlanningRelatedData(row)
}
const refreshCurrentPlanning = async () => {
if (!currentPlanning.value?.id) {
return
}
await loadPlanningRelatedData(currentPlanning.value)
}
const openEditDialog = () => {
if (!formData.value) {
return
}
editForm.value = { ...formData.value }
dialogVisible.value = true
}
const updateProjectRatio = (key: string, value?: number) => {
if (!editForm.value || key === 'officeRatio') {
return
}
;(editForm.value as unknown as Record<string, number | undefined>)[key] =
fromPercentValue(value) ?? 0
recalculateOfficeRatio()
}
const updateSpecialtyRatio = (key: string, value?: number) => {
if (!editForm.value || key === 'archRatio') {
return
}
;(editForm.value as unknown as Record<string, number | undefined>)[key] =
fromPercentValue(value) ?? 0
recalculateArchRatio()
}
const handleSave = async () => {
if (!editForm.value) {
return
}
if (Math.abs(projectRatioTotal.value - 100) >= 0.001) {
message.warning('项目层比例合计必须等于 100%')
return
}
if (Math.abs(specialtyRatioTotal.value - 100) >= 0.001) {
message.warning('专业层比例合计必须等于 100%')
return
}
saveLoading.value = true
try {
await OutputSplitApi.saveProjectOutputSplit({
planningId: editForm.value.planningId,
projectLeadRatio: editForm.value.projectLeadRatio,
officeRatio: editForm.value.officeRatio,
archRatio: editForm.value.archRatio,
decorRatio: editForm.value.decorRatio,
structRatio: editForm.value.structRatio,
waterRatio: editForm.value.waterRatio,
elecRatio: editForm.value.elecRatio,
hvacRatio: editForm.value.hvacRatio,
digitalRatio: editForm.value.digitalRatio
})
message.success('保存成功')
dialogVisible.value = false
await refreshCurrentPlanning()
} finally {
saveLoading.value = false
}
}
onMounted(() => {
getProjectList()
})
onActivated(() => {
getProjectList()
})
</script>
<style lang="scss" scoped>
.ratio-card {
padding: 12px;
border: 1px solid var(--el-border-color);
border-radius: 8px;
}
.ratio-title {
margin-bottom: 8px;
font-size: 13px;
font-weight: 600;
}
.ratio-amount {
margin-top: 8px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
</style>