fix(report): 报表统计修复批次——对账口径、导出审计与月份校验
- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
294d99a0d3
commit
ebb3a92f25
21 files changed
+2377
-287
No files matched your search
@@ -45,13 +45,26 @@ export interface BusinessRoleUpdateParams {
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
/** 分页查询岗位角色(含停用) */
|
||||
/** 岗位下拉选项(登录即可;基础数据与报表筛选用,含停用) */
|
||||
export interface BusinessRoleOption {
|
||||
id: number
|
||||
role_code: string
|
||||
role_name: string
|
||||
status: number
|
||||
}
|
||||
|
||||
/** 分页查询岗位角色(含停用,仅管理员) */
|
||||
export function listBusinessRoles(
|
||||
params: BusinessRoleQueryParams,
|
||||
): Promise<PageData<BusinessRoleItem>> {
|
||||
return request({ url: '/v1/system/business-roles', method: 'get', params })
|
||||
}
|
||||
|
||||
/** 只读岗位选项:财务/领导做报表筛选时不走管理员接口 */
|
||||
export function listBusinessRoleOptions(): Promise<BusinessRoleOption[]> {
|
||||
return request({ url: '/v1/base/business-roles/options', method: 'get' })
|
||||
}
|
||||
|
||||
/** 新增岗位 */
|
||||
export function createBusinessRole(
|
||||
data: BusinessRoleCreateParams,
|
||||
|
||||
@@ -47,6 +47,9 @@ export interface DepartmentMemberRow {
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
is_probation: number
|
||||
/** regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单 */
|
||||
member_kind: string
|
||||
unassigned_reason: string | null
|
||||
performance_sum: string | null
|
||||
payable_sum: string | null
|
||||
adjust_extra: string
|
||||
@@ -63,7 +66,14 @@ export interface DepartmentTeamRow {
|
||||
member_count: number
|
||||
team_base: string
|
||||
team_share_amount: string
|
||||
/** 单经理 5% 名义值;无月结聚合行时为 0(不发放) */
|
||||
company_share_amount: string
|
||||
/** 本月是否有月结聚合行(决定是否发放 5%) */
|
||||
has_aggregate: boolean
|
||||
/** 月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0 */
|
||||
aggregate_amount: string
|
||||
/** 团队应发小计 = 聚合行金额 + 调整净额 */
|
||||
team_subtotal: string
|
||||
company_base_ratio: string
|
||||
adjust_extra: string
|
||||
adjust_deduct: string
|
||||
@@ -74,6 +84,27 @@ export interface DepartmentTeamRow {
|
||||
members: DepartmentMemberRow[]
|
||||
}
|
||||
|
||||
/** 签字稿对账块:应发 = 成员应付 + 未分配应付 + 聚合行金额 + 调整净额 */
|
||||
export interface DepartmentReconciliation {
|
||||
team_member_payable_total: string
|
||||
unassigned_payable_total: string
|
||||
team_share_total: string
|
||||
company_share_total: string
|
||||
aggregate_payable_total: string
|
||||
aggregate_rounding_diff: string
|
||||
adjust_net_total: string
|
||||
payable_total: string
|
||||
team_base_total: string
|
||||
unassigned_performance_total: string
|
||||
other_team_base: string
|
||||
other_team_member_payable_total: string
|
||||
other_team_share_total: string
|
||||
other_team_company_share_total: string
|
||||
other_team_aggregate_total: string
|
||||
other_adjust_net_total: string
|
||||
company_base: string
|
||||
}
|
||||
|
||||
export interface DepartmentSummary {
|
||||
settlement_month: string
|
||||
params: {
|
||||
@@ -88,8 +119,14 @@ export interface DepartmentSummary {
|
||||
team_base_total: string
|
||||
company_base: string
|
||||
company_share_pct: string
|
||||
/** 单经理 5% 名义值 */
|
||||
company_share_amount: string
|
||||
/** 计入应发的经理数(有聚合行) */
|
||||
company_share_count: number
|
||||
/** 5% 合计 = 单份 × 计入次数 */
|
||||
company_share_total: string
|
||||
} | null
|
||||
reconciliation: DepartmentReconciliation | null
|
||||
totals: Record<string, string>
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,11 @@ import { handledError } from '@/utils/error'
|
||||
* 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位
|
||||
* - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位
|
||||
*/
|
||||
export function usePagedList<T, F extends object>(
|
||||
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<PageData<T>>,
|
||||
export function usePagedList<T, F extends object, D extends PageData<T> = PageData<T>>(
|
||||
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<D>,
|
||||
getFilters: () => F,
|
||||
/** 仅最新请求的响应会触发(fetcher 内的副作用不受序号保护) */
|
||||
onData?: (data: D) => void,
|
||||
) {
|
||||
const records = ref([]) as Ref<T[]>
|
||||
const total = ref(0)
|
||||
@@ -39,6 +41,7 @@ export function usePagedList<T, F extends object>(
|
||||
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
|
||||
records.value = data.list
|
||||
total.value = data.total
|
||||
onData?.(data)
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
} finally {
|
||||
|
||||
@@ -69,9 +69,9 @@ const managerOptions = ref<SelectOption[]>([])
|
||||
|
||||
async function loadRoleOptions(): Promise<void> {
|
||||
try {
|
||||
// 岗位规模有限(内置 3 个 + 自定义),一页足够
|
||||
const data = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
|
||||
roleOptions.value = data.list.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
|
||||
const data = await businessRoleApi.listBusinessRoleOptions()
|
||||
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
|
||||
@@ -60,14 +60,15 @@ function getFilters(): ReportFilterParams {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(params: ReportFilterParams) {
|
||||
const data = await reportApi.listBusinessStats(params)
|
||||
summary.value = data.summary
|
||||
return data
|
||||
function fetchPage(params: ReportFilterParams) {
|
||||
return reportApi.listBusinessStats(params)
|
||||
}
|
||||
|
||||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<BusinessStatsRow, ReportFilterParams>(fetchPage, getFilters)
|
||||
usePagedList(fetchPage, getFilters, (data) => {
|
||||
summary.value = data.summary
|
||||
})
|
||||
|
||||
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
|
||||
onMounted(search)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@@ -14,8 +14,8 @@ import { handledError } from '@/utils/error'
|
||||
*
|
||||
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
|
||||
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
|
||||
* 冻结额(frozen_amount)对账。全公司 5% 部分单列——各团队行展示的
|
||||
* 5% 金额是同一基数的名义值,不能相加。
|
||||
* 冻结额(frozen_amount)对账。全公司分成每位有聚合行的经理各计一次,
|
||||
* 团队应发小计与月结聚合行同口径;列头比例随在线参数变化。
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
@@ -35,6 +35,18 @@ void loadManagers()
|
||||
const data = ref<DepartmentSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
let loadSeq = 0
|
||||
|
||||
// 比例来自在线参数,列头/卡片不能写死 12.5% / 5%
|
||||
const teamShareLabel = computed(
|
||||
() => `团队分成(${data.value?.params?.team_text ?? '—'}%)`,
|
||||
)
|
||||
const companyShareLabel = computed(
|
||||
() => `全公司分成(${data.value?.params?.company_text ?? '—'}%)`,
|
||||
)
|
||||
const companyTitle = computed(
|
||||
() => `全公司 ${data.value?.params?.company_text ?? '—'}% 部分(单列)`,
|
||||
)
|
||||
|
||||
function currentMonth(): string {
|
||||
const now = new Date()
|
||||
@@ -46,16 +58,18 @@ async function load(): Promise<void> {
|
||||
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
|
||||
return
|
||||
}
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await reportApi.getDepartmentSummary({
|
||||
const result = await reportApi.getDepartmentSummary({
|
||||
settlement_month: filters.settlement_month,
|
||||
manager_id: filters.manager_id,
|
||||
})
|
||||
if (seq === loadSeq) data.value = result
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (seq === loadSeq) handledError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -164,11 +178,16 @@ function pct(value: string): string {
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="company-card">
|
||||
<span class="company-title">全公司 5% 部分(单列)</span>
|
||||
<span class="company-title">{{ companyTitle }}</span>
|
||||
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</span>
|
||||
<span>5% 分成:{{ data?.company?.company_share_amount ?? '—' }}</span>
|
||||
<span>
|
||||
单经理 {{ data?.params?.company_text ?? '—' }}% 分成:
|
||||
{{ data?.company?.company_share_amount ?? '—' }} ×
|
||||
{{ data?.company?.company_share_count ?? 0 }} 人 =
|
||||
{{ data?.company?.company_share_total ?? '—' }}
|
||||
</span>
|
||||
<span class="company-note">
|
||||
各团队行的 5% 金额基于同一全公司基数,不可跨团队相加
|
||||
每位有月结聚合行的经理各计一次;零成员团队无聚合行,不产生分成
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -210,8 +229,8 @@ function pct(value: string): string {
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
|
||||
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
|
||||
<el-table-column prop="team_share_amount" label="12.5% 分成" width="120" align="right" />
|
||||
<el-table-column prop="company_share_amount" label="5% 分成" width="110" align="right" />
|
||||
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
|
||||
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
|
||||
<el-table-column label="占全公司比" width="110" align="right">
|
||||
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@@ -40,66 +40,86 @@ function getFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
interface DrillState {
|
||||
rows: PaymentDetailRow[]
|
||||
total: number
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
loading: boolean
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** 每个桶独立的下钻状态:同时展开多个区间时互不覆盖 */
|
||||
const drills = reactive<Record<string, DrillState>>({})
|
||||
|
||||
function drillOf(code: string): DrillState {
|
||||
drills[code] ??= { rows: [], total: 0, pageNum: 1, pageSize: 10, loading: false, seq: 0 }
|
||||
return drills[code]
|
||||
}
|
||||
|
||||
let loadSeq = 0
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await reportApi.getPaymentStats(getFilters())
|
||||
const result = await reportApi.getPaymentStats(getFilters())
|
||||
if (seq !== loadSeq) return
|
||||
data.value = result
|
||||
// 筛选条件变化后旧桶明细不再对应当前口径;同时预建各桶状态,
|
||||
// 避免模板渲染时才创建响应式属性
|
||||
for (const code of Object.keys(drills)) delete drills[code]
|
||||
for (const bucket of result.buckets) drillOf(bucket.code)
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (seq === loadSeq) handledError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
/** 桶展开下钻:分页明细按 (桶编码) 缓存当前页 */
|
||||
const drill = reactive({
|
||||
bucket: '',
|
||||
rows: [] as PaymentDetailRow[],
|
||||
total: 0,
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
async function loadDrill(bucket: BucketRow, expanded: boolean): Promise<void> {
|
||||
if (!expanded) return
|
||||
drill.bucket = bucket.code
|
||||
drill.pageNum = 1
|
||||
await fetchDrill()
|
||||
function loadDrill(bucket: BucketRow, expandedRows: BucketRow[]): void {
|
||||
// Element Plus 2.x 的第二参是当前展开行数组,不是 boolean;
|
||||
// 收起时该桶不在数组里,自然不再请求
|
||||
if (!expandedRows.some((row) => row.code === bucket.code)) return
|
||||
drillOf(bucket.code).pageNum = 1
|
||||
void fetchDrill(bucket.code)
|
||||
}
|
||||
|
||||
async function fetchDrill(): Promise<void> {
|
||||
if (!drill.bucket) return
|
||||
drill.loading = true
|
||||
async function fetchDrill(code: string): Promise<void> {
|
||||
const state = drillOf(code)
|
||||
const seq = ++state.seq
|
||||
state.loading = true
|
||||
try {
|
||||
const page = await reportApi.listPaymentDetail({
|
||||
...getFilters(),
|
||||
bucket: drill.bucket,
|
||||
pageNum: drill.pageNum,
|
||||
pageSize: drill.pageSize,
|
||||
bucket: code,
|
||||
pageNum: state.pageNum,
|
||||
pageSize: state.pageSize,
|
||||
})
|
||||
drill.rows = page.list
|
||||
drill.total = page.total
|
||||
if (seq !== state.seq) return
|
||||
state.rows = page.list
|
||||
state.total = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (seq === state.seq) handledError(error)
|
||||
} finally {
|
||||
drill.loading = false
|
||||
if (seq === state.seq) state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDrillPage(page: number): void {
|
||||
drill.pageNum = page
|
||||
void fetchDrill()
|
||||
function onDrillPage(code: string, page: number): void {
|
||||
drillOf(code).pageNum = page
|
||||
void fetchDrill(code)
|
||||
}
|
||||
|
||||
function onDrillSize(size: number): void {
|
||||
drill.pageSize = size
|
||||
drill.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||||
void fetchDrill()
|
||||
function onDrillSize(code: string, size: number): void {
|
||||
const state = drillOf(code)
|
||||
state.pageSize = size
|
||||
state.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||||
void fetchDrill(code)
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
async function doExport(): Promise<void> {
|
||||
exporting.value = true
|
||||
try {
|
||||
@@ -198,15 +218,22 @@ function today(): string {
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
class="bucket-table"
|
||||
data-testid="payment-bucket-table"
|
||||
:data="data?.buckets ?? []"
|
||||
row-key="code"
|
||||
stripe
|
||||
@expand-change="loadDrill"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default>
|
||||
<template #default="{ row }">
|
||||
<div class="drill-wrap">
|
||||
<el-table v-loading="drill.loading" :data="drill.rows" size="small" stripe>
|
||||
<el-table
|
||||
v-loading="drillOf(row.code).loading"
|
||||
:data="drillOf(row.code).rows"
|
||||
size="small"
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="150" />
|
||||
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||
@@ -226,11 +253,11 @@ function today(): string {
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
||||
</el-table>
|
||||
<AppPagination
|
||||
:total="drill.total"
|
||||
:page-num="drill.pageNum"
|
||||
:page-size="drill.pageSize"
|
||||
@page-change="onDrillPage"
|
||||
@size-change="onDrillSize"
|
||||
:total="drillOf(row.code).total"
|
||||
:page-num="drillOf(row.code).pageNum"
|
||||
:page-size="drillOf(row.code).pageSize"
|
||||
@page-change="onDrillPage(row.code, $event)"
|
||||
@size-change="onDrillSize(row.code, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import * as calcApi from '@/api/calc'
|
||||
import type { LedgerRow } from '@/api/calc'
|
||||
@@ -9,7 +9,7 @@ import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
|
||||
import { listPersonnel } from '@/api/personnel'
|
||||
import * as reportApi from '@/api/reports'
|
||||
import type { PersonalDetailRow, ReportFilterParams, ReportSummary } from '@/api/reports'
|
||||
import { listBusinessRoles } from '@/api/businessRoles'
|
||||
import { listBusinessRoleOptions } from '@/api/businessRoles'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
@@ -23,6 +23,7 @@ import { handledError } from '@/utils/error'
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const filters = reactive({
|
||||
settlement_month: '',
|
||||
@@ -32,14 +33,30 @@ const filters = reactive({
|
||||
role_code: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 部门汇总「查看明细」下钻:路由 query 必须真正落到筛选条件上。
|
||||
*
|
||||
* 无 query 时也要清空(不能只覆盖):从菜单再次进入同一页面时组件复用,
|
||||
* 旧的 personnel_id/settlement_month 会残留,用户看到的仍是被下钻者的数据。
|
||||
*/
|
||||
function applyRouteQuery(): void {
|
||||
const personnelId = Number(route.query.personnel_id)
|
||||
filters.personnel_id =
|
||||
Number.isInteger(personnelId) && personnelId > 0 ? personnelId : undefined
|
||||
const month = route.query.settlement_month
|
||||
filters.settlement_month =
|
||||
typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : ''
|
||||
}
|
||||
applyRouteQuery()
|
||||
|
||||
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
|
||||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||||
),
|
||||
)
|
||||
const { options: roleOptions, load: loadRoles } = useDropdownOptions(() =>
|
||||
listBusinessRoles({ pageNum: 1, pageSize: 200 }).then((data) =>
|
||||
data.list.map((item) => ({ label: item.role_name, value: item.role_code })),
|
||||
listBusinessRoleOptions().then((data) =>
|
||||
data.map((item) => ({ label: item.role_name, value: item.role_code })),
|
||||
),
|
||||
)
|
||||
void loadPersonnel()
|
||||
@@ -58,18 +75,28 @@ function getFilters(): ReportFilterParams {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(params: ReportFilterParams) {
|
||||
const data = await reportApi.listPersonalDetail(params)
|
||||
summary.value = data.summary
|
||||
return data
|
||||
function fetchPage(params: ReportFilterParams) {
|
||||
return reportApi.listPersonalDetail(params)
|
||||
}
|
||||
|
||||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<PersonalDetailRow, ReportFilterParams>(fetchPage, getFilters)
|
||||
usePagedList(fetchPage, getFilters, (data) => {
|
||||
summary.value = data.summary
|
||||
})
|
||||
|
||||
// 进页即加载(与月结/台账页修复后的行为一致;筛选条件默认为「全部」)
|
||||
// 进页即加载(筛选条件可能已由部门汇总下钻带入)
|
||||
onMounted(search)
|
||||
|
||||
// 同一路由再次下钻(例如从部门汇总换成员)时重新应用 query
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
applyRouteQuery()
|
||||
search()
|
||||
},
|
||||
)
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.settlement_month = ''
|
||||
filters.personnel_id = undefined
|
||||
|
||||
@@ -7,7 +7,7 @@ import { customerTypeLabel } from '@/api/customers'
|
||||
import * as reportApi from '@/api/reports'
|
||||
import type {
|
||||
BusinessStatsRow,
|
||||
DepartmentMemberRow,
|
||||
DepartmentSummary,
|
||||
PersonalDetailRow,
|
||||
ReportFilterParams,
|
||||
} from '@/api/reports'
|
||||
@@ -38,12 +38,30 @@ const title = ref('')
|
||||
// 表格行是四类报表的并集,模板按报表编码取用各自字段;用宽松记录类型
|
||||
// 避免为四种行形态各写一套模板 props
|
||||
const rows = ref<Record<string, any>[]>([])
|
||||
const department = ref<DepartmentSummary | null>(null)
|
||||
const buckets = ref<Record<string, any>[]>([])
|
||||
const groups = ref<{ title: string; rows: Record<string, any>[] }[]>([])
|
||||
const loading = ref(true)
|
||||
const truncated = ref(false)
|
||||
const printedAt = ref(new Date())
|
||||
|
||||
/** 部门汇总成员明细展平(团队列冗余,与签字稿其他块一致) */
|
||||
const departmentMembers = computed(() =>
|
||||
(department.value?.teams ?? []).flatMap((team) =>
|
||||
team.members.map((member) => ({ manager_name: team.manager_name, ...member })),
|
||||
),
|
||||
)
|
||||
|
||||
const MEMBER_KIND_LABELS: Record<string, string> = {
|
||||
regular: '普通绩效',
|
||||
manager_personal: '经理个人户',
|
||||
adjustment: '仅调整单',
|
||||
}
|
||||
|
||||
function memberKindLabel(kind: string): string {
|
||||
return MEMBER_KIND_LABELS[kind] ?? kind
|
||||
}
|
||||
|
||||
const filterText = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
settlement_month: '统计月份',
|
||||
@@ -71,13 +89,19 @@ async function fetchAll<T>(
|
||||
): Promise<{ list: T[]; truncated: boolean }> {
|
||||
const collected: T[] = []
|
||||
let pageNum = 1
|
||||
let total = 0
|
||||
while (collected.length < PRINT_MAX_ROWS) {
|
||||
const data = await fetcher({ ...params, pageNum, pageSize: PAGE_SIZE })
|
||||
total = data.total
|
||||
collected.push(...data.list)
|
||||
if (collected.length >= data.total || data.list.length < PAGE_SIZE) break
|
||||
pageNum += 1
|
||||
}
|
||||
return { list: collected.slice(0, PRINT_MAX_ROWS), truncated: collected.length > PRINT_MAX_ROWS }
|
||||
// 以接口 total 判定:collected 最多正好等于上限,用长度比较永远为 false
|
||||
return {
|
||||
list: collected.slice(0, PRINT_MAX_ROWS),
|
||||
truncated: total > PRINT_MAX_ROWS,
|
||||
}
|
||||
}
|
||||
|
||||
function personalRow(row: PersonalDetailRow): Record<string, any> {
|
||||
@@ -109,18 +133,7 @@ async function load(): Promise<void> {
|
||||
truncated.value = data.truncated
|
||||
} else if (code.value === 'department-summary') {
|
||||
title.value = '部门绩效工资汇总表'
|
||||
const data = await reportApi.getDepartmentSummary(queryParams.value)
|
||||
rows.value = data.teams.flatMap((team) =>
|
||||
team.members.map((member: DepartmentMemberRow) => ({
|
||||
manager_name: team.manager_name,
|
||||
member_name: member.personnel_name,
|
||||
role_code: member.role_code,
|
||||
performance_sum: member.performance_sum,
|
||||
payable_sum: member.payable_sum,
|
||||
adjust_net: member.adjust_net,
|
||||
net_total: member.net_total,
|
||||
})),
|
||||
)
|
||||
department.value = await reportApi.getDepartmentSummary(queryParams.value)
|
||||
} else if (code.value === 'business-stats') {
|
||||
title.value = '业务统计表'
|
||||
const data = await fetchAll(
|
||||
@@ -169,12 +182,13 @@ onMounted(load)
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<div class="meta">
|
||||
<span>筛选条件:{{ filterText }}</span>
|
||||
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.0</span>
|
||||
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="truncated" class="truncated">
|
||||
数据量超过打印上限(2000 行),仅打印前 2000 行;请缩小筛选范围后重新打印。
|
||||
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
|
||||
行;请缩小筛选范围后重新打印。
|
||||
</div>
|
||||
|
||||
<template v-if="code === 'personal-detail'">
|
||||
@@ -204,23 +218,171 @@ onMounted(load)
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-else-if="code === 'department-summary'">
|
||||
<table class="sheet-table">
|
||||
<template v-else-if="code === 'department-summary' && department">
|
||||
<h2 class="block-title">一、团队分成汇总</h2>
|
||||
<table class="sheet-table" data-testid="print-department-teams">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>经理团队</th><th>成员</th><th>岗位</th><th>绩效金额</th>
|
||||
<th>应付绩效</th><th>调整净额</th><th>净合计</th>
|
||||
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
|
||||
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
|
||||
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in rows" :key="index">
|
||||
<td>{{ row.manager_name }}</td>
|
||||
<td>{{ row.member_name }}</td>
|
||||
<td>{{ row.role_code }}</td>
|
||||
<td class="num">{{ row.performance_sum }}</td>
|
||||
<td class="num">{{ row.payable_sum }}</td>
|
||||
<td class="num">{{ row.adjust_net }}</td>
|
||||
<td class="num">{{ row.net_total }}</td>
|
||||
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ team.manager_name }}</td>
|
||||
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
|
||||
<td class="num">{{ team.member_count }}</td>
|
||||
<td class="num">{{ team.team_base }}</td>
|
||||
<td class="num">{{ team.team_share_amount }}</td>
|
||||
<td class="num">{{ team.adjust_net }}</td>
|
||||
<td class="num">{{ team.team_subtotal }}</td>
|
||||
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">
|
||||
二、全公司分成(每位有聚合行的经理各计一次,共
|
||||
{{ department.company?.company_share_count ?? 0 }} 次)
|
||||
</h2>
|
||||
<table class="sheet-table" data-testid="print-company-block">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
|
||||
<th>计入次数</th><th>分成合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
|
||||
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
|
||||
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
|
||||
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
|
||||
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">三、对账汇总</h2>
|
||||
<table class="sheet-table" data-testid="print-reconciliation">
|
||||
<thead>
|
||||
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>团队成员应付合计</td>
|
||||
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>未分配人员应付合计</td>
|
||||
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>团队分成合计(分列)</td>
|
||||
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
|
||||
</td>
|
||||
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
|
||||
<td>每位有聚合行的经理各计一次</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>聚合行舍入差</td>
|
||||
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
|
||||
<td>分列舍入与聚合行一次舍入的差</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>调整净额合计(明细层)</td>
|
||||
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>应发合计</td>
|
||||
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
|
||||
<td>上列各项之和</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>团队基数合计</td>
|
||||
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>未分配绩效合计</td>
|
||||
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>其他未展示基数</td>
|
||||
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
|
||||
<td>筛选时含其他团队与全公司未分配</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>全公司基数</td>
|
||||
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">四、团队成员明细</h2>
|
||||
<table class="sheet-table" data-testid="print-department-members">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
|
||||
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="member in departmentMembers"
|
||||
:key="`${member.personnel_id}-${member.member_kind}`"
|
||||
>
|
||||
<td>{{ member.manager_name }}</td>
|
||||
<td>{{ member.personnel_name }}</td>
|
||||
<td>{{ member.role_code }}</td>
|
||||
<td>{{ memberKindLabel(member.member_kind) }}</td>
|
||||
<td class="num">{{ member.performance_sum }}</td>
|
||||
<td class="num">{{ member.payable_sum }}</td>
|
||||
<td class="num">{{ member.adjust_extra }}</td>
|
||||
<td class="num">{{ member.adjust_deduct }}</td>
|
||||
<td class="num">{{ member.adjust_net }}</td>
|
||||
<td class="num">{{ member.net_total }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">五、未分配人员明细</h2>
|
||||
<table class="sheet-table" data-testid="print-unassigned">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
|
||||
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="member in department.unassigned"
|
||||
:key="`${member.personnel_id}-${member.member_kind}`"
|
||||
>
|
||||
<td>{{ member.personnel_name }}</td>
|
||||
<td>{{ member.role_code }}</td>
|
||||
<td>{{ member.unassigned_reason ?? '—' }}</td>
|
||||
<td class="num">{{ member.performance_sum }}</td>
|
||||
<td class="num">{{ member.payable_sum }}</td>
|
||||
<td class="num">{{ member.adjust_extra }}</td>
|
||||
<td class="num">{{ member.adjust_deduct }}</td>
|
||||
<td class="num">{{ member.adjust_net }}</td>
|
||||
<td class="num">{{ member.net_total }}</td>
|
||||
</tr>
|
||||
<tr v-if="department.unassigned.length === 0">
|
||||
<td colspan="9">(无)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -374,6 +536,12 @@ onMounted(load)
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 18px 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-block {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user