Files
PayrollManagementSystem/frontend/src/views/reports/business-stats/index.vue
T
liuwenxinandClaude Code ebb3a92f25 fix(report): 报表统计修复批次——对账口径、导出审计与月份校验
- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次,
  团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍
  (有调整单时 + adjust_net_total),分列舍入差单列
- 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入,
  团队与未分配不重复;按经理筛选只汇总展示团队
- 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称
- 月份校验抽 core/month,报表/月结/调整单非法月份统一 400;
  岗位只读选项供财务/领导筛选
- 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、
  summary 请求序号保护

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-20 09:44:43 +08:00

321 lines
11 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.
<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>