- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code <noreply@anthropic.com>
321 lines
11 KiB
Vue
321 lines
11 KiB
Vue
<script setup lang="ts">
|
||
import { onMounted, reactive, ref } from 'vue'
|
||
import { ElMessage } from 'element-plus'
|
||
import { useRouter } from 'vue-router'
|
||
|
||
import { BUSINESS_STATUS_OPTIONS, businessStatusLabel } from '@/api/businesses'
|
||
import * as calcApi from '@/api/calc'
|
||
import type { LedgerRow } from '@/api/calc'
|
||
import { listCustomers } from '@/api/customers'
|
||
import { listPersonnel } from '@/api/personnel'
|
||
import * as receiptApi from '@/api/receipts'
|
||
import * as reportApi from '@/api/reports'
|
||
import type { BusinessStatsRow, ReportFilterParams, ReportSummary } from '@/api/reports'
|
||
import AppPagination from '@/components/table/AppPagination.vue'
|
||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||
import { usePagedList } from '@/composables/usePagedList'
|
||
import { handledError } from '@/utils/error'
|
||
|
||
/**
|
||
* 业务统计表(FR-031 之一):按业务查发货、回款、绩效情况。
|
||
*
|
||
* 回款额/绩效是**业务累计值**,不随业务日期筛选变化(页面已注明);
|
||
* 行展开下钻回款明细与台账明细(复用既有查询)。
|
||
*/
|
||
|
||
const router = useRouter()
|
||
|
||
const filters = reactive({
|
||
keyword: '',
|
||
customer_id: undefined as number | undefined,
|
||
owner_personnel_id: undefined as number | undefined,
|
||
status: undefined as number | undefined,
|
||
})
|
||
const dateRange = ref<[string, string] | null>(null)
|
||
|
||
const { options: customerOptions, load: loadCustomers } = useDropdownOptions(() =>
|
||
listCustomers({ pageNum: 1, pageSize: 200 }).then((data) =>
|
||
data.list.map((item) => ({ label: item.company_name, value: item.id })),
|
||
),
|
||
)
|
||
const { options: personnelOptions, load: loadPersonnelOptions } = useDropdownOptions(() =>
|
||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||
),
|
||
)
|
||
void loadCustomers()
|
||
void loadPersonnelOptions()
|
||
|
||
const summary = ref<ReportSummary | null>(null)
|
||
|
||
function getFilters(): ReportFilterParams {
|
||
return {
|
||
keyword: filters.keyword.trim() || undefined,
|
||
customer_id: filters.customer_id,
|
||
owner_personnel_id: filters.owner_personnel_id,
|
||
status: filters.status,
|
||
// el-date-picker 必须指定 value-format,否则绑定值是 Date 对象
|
||
date_from: dateRange.value?.[0],
|
||
date_to: dateRange.value?.[1],
|
||
}
|
||
}
|
||
|
||
function fetchPage(params: ReportFilterParams) {
|
||
return reportApi.listBusinessStats(params)
|
||
}
|
||
|
||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||
usePagedList(fetchPage, getFilters, (data) => {
|
||
summary.value = data.summary
|
||
})
|
||
|
||
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
|
||
onMounted(search)
|
||
|
||
function resetFilters(): void {
|
||
filters.keyword = ''
|
||
filters.customer_id = undefined
|
||
filters.owner_personnel_id = undefined
|
||
filters.status = undefined
|
||
dateRange.value = null
|
||
search()
|
||
}
|
||
|
||
/** 展开行下钻:回款明细与台账明细各自懒加载并缓存 */
|
||
const receiptsMap = ref<Record<number, receiptApi.ReceiptItem[]>>({})
|
||
const ledgersMap = ref<Record<number, LedgerRow[]>>({})
|
||
|
||
async function loadDrill(row: BusinessStatsRow): Promise<void> {
|
||
const id = row.business_id
|
||
try {
|
||
if (!receiptsMap.value[id]) {
|
||
const data = await receiptApi.listReceipts({ business_id: id, pageNum: 1, pageSize: 200 })
|
||
receiptsMap.value = { ...receiptsMap.value, [id]: data.list }
|
||
}
|
||
if (!ledgersMap.value[id]) {
|
||
const data = await calcApi.listLedger({
|
||
business_id: id,
|
||
is_active: 1,
|
||
calc_type: 1,
|
||
pageNum: 1,
|
||
pageSize: 200,
|
||
})
|
||
ledgersMap.value = { ...ledgersMap.value, [id]: data.list }
|
||
}
|
||
} catch (error) {
|
||
handledError(error)
|
||
}
|
||
}
|
||
|
||
const exporting = ref(false)
|
||
|
||
async function doExport(): Promise<void> {
|
||
if (total.value === 0) {
|
||
ElMessage.warning('当前筛选无数据,无法导出')
|
||
return
|
||
}
|
||
exporting.value = true
|
||
try {
|
||
const blob = await reportApi.exportReport('business-stats', getFilters())
|
||
const url = URL.createObjectURL(blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = `业务统计表_${today()}.xlsx`
|
||
link.click()
|
||
URL.revokeObjectURL(url)
|
||
ElMessage.success('已导出')
|
||
} catch (error) {
|
||
handledError(error)
|
||
} finally {
|
||
exporting.value = false
|
||
}
|
||
}
|
||
|
||
function doPrint(): void {
|
||
const query: Record<string, string> = {}
|
||
for (const [key, value] of Object.entries(getFilters())) {
|
||
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
|
||
}
|
||
const route = router.resolve({
|
||
name: 'report-print',
|
||
params: { code: 'business-stats' },
|
||
query,
|
||
})
|
||
window.open(route.href, '_blank')
|
||
}
|
||
|
||
function today(): string {
|
||
const now = new Date()
|
||
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
|
||
now.getDate(),
|
||
).padStart(2, '0')}`
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="page">
|
||
<el-form inline class="filters" @submit.prevent>
|
||
<el-form-item label="关键字">
|
||
<el-input
|
||
v-model="filters.keyword"
|
||
placeholder="业务编号/客户名称"
|
||
clearable
|
||
style="width: 180px"
|
||
@keyup.enter="search"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item label="客户">
|
||
<el-select
|
||
v-model="filters.customer_id"
|
||
placeholder="全部客户"
|
||
clearable
|
||
filterable
|
||
style="width: 160px"
|
||
>
|
||
<el-option
|
||
v-for="option in customerOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="归属人">
|
||
<el-select
|
||
v-model="filters.owner_personnel_id"
|
||
placeholder="全部人员"
|
||
clearable
|
||
filterable
|
||
style="width: 150px"
|
||
>
|
||
<el-option
|
||
v-for="option in personnelOptions"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="状态">
|
||
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
|
||
<el-option
|
||
v-for="option in BUSINESS_STATUS_OPTIONS"
|
||
:key="option.value"
|
||
:label="option.label"
|
||
:value="option.value"
|
||
/>
|
||
</el-select>
|
||
</el-form-item>
|
||
<el-form-item label="业务日期">
|
||
<el-date-picker
|
||
v-model="dateRange"
|
||
type="daterange"
|
||
value-format="YYYY-MM-DD"
|
||
range-separator="至"
|
||
start-placeholder="开始"
|
||
end-placeholder="结束"
|
||
style="width: 240px"
|
||
/>
|
||
</el-form-item>
|
||
<el-form-item>
|
||
<el-button type="primary" @click="search">查询</el-button>
|
||
<el-button @click="resetFilters">重置</el-button>
|
||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||
<el-button @click="doPrint">打印</el-button>
|
||
</el-form-item>
|
||
</el-form>
|
||
|
||
<div class="hint">回款额与绩效为业务累计值,不随业务日期筛选变化</div>
|
||
|
||
<el-table v-loading="loading" :data="records" stripe @expand-change="loadDrill">
|
||
<el-table-column type="expand">
|
||
<template #default="{ row }">
|
||
<div class="drill-wrap">
|
||
<h4>回款明细</h4>
|
||
<el-table :data="receiptsMap[row.business_id] ?? []" size="small" stripe>
|
||
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
|
||
<el-table-column prop="settlement_status" label="入结状态" width="100" />
|
||
<el-table-column prop="remark" label="备注" min-width="140" />
|
||
</el-table>
|
||
<h4>台账明细</h4>
|
||
<el-table :data="ledgersMap[row.business_id] ?? []" size="small" stripe>
|
||
<el-table-column prop="personnel_name" label="归属人员" width="120" />
|
||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||
<el-table-column prop="rate_text" label="计提比例" width="110" />
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
||
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
|
||
</el-table>
|
||
</div>
|
||
</template>
|
||
</el-table-column>
|
||
<el-table-column prop="business_no" label="业务编号" min-width="160" />
|
||
<el-table-column prop="business_date" label="业务日期" width="110" />
|
||
<el-table-column prop="customer_name" label="客户" min-width="170" />
|
||
<el-table-column prop="owner_personnel_name" label="归属人" width="110" />
|
||
<el-table-column label="状态" width="100">
|
||
<template #default="{ row }">{{ businessStatusLabel(row.status) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="total_sale_amount" label="销售额" width="120" align="right" />
|
||
<el-table-column prop="total_ship_amount" label="发货额" width="120" align="right" />
|
||
<el-table-column prop="total_received_amount" label="回款额" width="120" align="right" />
|
||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
||
<el-table-column prop="payable_amount" label="应付绩效" width="120" align="right" />
|
||
</el-table>
|
||
|
||
<div v-if="summary" class="summary-bar">
|
||
全部筛选结果:{{ summary.row_count }} 笔业务 | 发货合计 {{ summary.ship_total ?? '0.00' }}
|
||
| 回款合计 {{ summary.received_total ?? '0.00' }} | 绩效合计
|
||
{{ summary.performance_total ?? '0.00' }}
|
||
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
||
</div>
|
||
|
||
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum) -->
|
||
<AppPagination
|
||
:total="total"
|
||
:page-num="pageNum"
|
||
:page-size="pageSize"
|
||
@page-change="onPageChange"
|
||
@size-change="onSizeChange"
|
||
/>
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.filters {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.hint {
|
||
margin-bottom: 8px;
|
||
font-size: 12px;
|
||
color: var(--ys-ink-3);
|
||
}
|
||
|
||
.drill-wrap {
|
||
padding: 8px 16px;
|
||
}
|
||
|
||
.drill-wrap h4 {
|
||
margin: 8px 0;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.summary-bar {
|
||
margin-top: 10px;
|
||
padding: 8px 12px;
|
||
font-size: 13px;
|
||
background: var(--ys-indigo-wash);
|
||
border-radius: 6px;
|
||
}
|
||
|
||
.summary-note {
|
||
color: var(--ys-ink-3);
|
||
font-size: 12px;
|
||
}
|
||
</style>
|