feat(report): 报表四页与打印视图
- 个人绩效明细/部门绩效汇总/业务统计/回款统计四页,路由组「报表统计」order19-22(allowedRoles admin/finance/leader) - 明细分页页脚全筛选合计;部门汇总成员内联与跨页下钻;业务统计展开回款/台账;回款统计 8 档区间(档位由后端下发)与桶下钻 - 打印视图独立顶层路由(不挂 BasicLayout):公司页眉走 public 端点、循环翻页取全量(2000 行截断提示)、@media print A4 横版每页表头、签字栏 - AppPagination 只派发 page-change/size-change,四页按事件绑定并补初始加载(@change/v-model 不生效的同类缺陷) - e2e report_flow.py:四页筛选与导出下载、打印视图 window.print 桩断言 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
1 parent
2a1939d2e7
commit
04a1a0d307
12 files changed
+2763
-1
No files matched your search
@@ -0,0 +1,300 @@
|
||||
"""报表统计端到端验证(M4 ⑤:四张报表 + 打印视图)。
|
||||
|
||||
覆盖真实浏览器链路(接口测试守不住的部分):
|
||||
- 「报表统计」组四页菜单可见、可进入
|
||||
- 个人明细:API 预置的已核算业务出现在列表;导出触发真实下载
|
||||
- 部门汇总:经理团队行出现且团队基数非 0;全公司 5% 卡片可见
|
||||
- 业务统计:关键字筛选命中;三列统计非空
|
||||
- 回款统计:8 档区间表;目标桶(61-90 天)有金额;空桶显示 0
|
||||
- 打印视图:独立路由带出报表与签字栏;window.print 被调用
|
||||
|
||||
前置(缺一不可):
|
||||
docker compose up -d
|
||||
cd backend && uv run uvicorn app.main:app --port 8000
|
||||
cd frontend && pnpm dev
|
||||
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/report_flow.py
|
||||
|
||||
数据说明:核算后数据不可删(业务规则),按 `e2e_` 前缀留档开发库;
|
||||
每次运行用随机后缀隔离,断言以本次业务编号定位。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
current_path,
|
||||
fail,
|
||||
login,
|
||||
)
|
||||
|
||||
ADMIN = "admin"
|
||||
SUFFIX = f"{int(time.time())}"
|
||||
console_errors: list[str] = []
|
||||
MONTH = time.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
|
||||
"""带令牌的轻量 API 调用(urllib,避免额外依赖)。"""
|
||||
request = urllib.request.Request(
|
||||
f"{BASE_URL}{path}",
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode()[:200]
|
||||
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
|
||||
if payload.get("code") != 200:
|
||||
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def step_login(page: Page, admin_password: str) -> None:
|
||||
login(page, ADMIN, admin_password)
|
||||
print("✓ 1. 登录")
|
||||
|
||||
|
||||
def step_setup_via_api(page: Page) -> dict:
|
||||
"""预置:经理 + 业务员 → 客户/业务 → 发货(60 天前)→ 回款 → 核算。"""
|
||||
token = page.evaluate("localStorage.getItem('ys_token')")
|
||||
if not token:
|
||||
fail("API 预置", "localStorage 中未取到 ys_token")
|
||||
|
||||
roles = _api("GET", "/api/v1/system/business-roles?pageNum=1&pageSize=50", token)["list"]
|
||||
salesperson_role = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None)
|
||||
manager_role = next((r["id"] for r in roles if r["role_code"] == "sales_manager"), None)
|
||||
if salesperson_role is None or manager_role is None:
|
||||
fail("API 预置", "未找到 salesperson / sales_manager 岗位种子")
|
||||
|
||||
manager = _api(
|
||||
"POST",
|
||||
"/api/v1/base/personnel",
|
||||
token,
|
||||
{
|
||||
"personnel_name": f"e2e_报表现经理_{SUFFIX}",
|
||||
"business_role_id": manager_role,
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
)
|
||||
person = _api(
|
||||
"POST",
|
||||
"/api/v1/base/personnel",
|
||||
token,
|
||||
{
|
||||
"personnel_name": f"e2e_报表现业务员_{SUFFIX}",
|
||||
"business_role_id": salesperson_role,
|
||||
"salary_standard": "3000.00",
|
||||
"manager_id": manager["id"],
|
||||
},
|
||||
)
|
||||
customer = _api(
|
||||
"POST",
|
||||
"/api/v1/base/customers",
|
||||
token,
|
||||
{
|
||||
"company_name": f"e2e_报表客户_{SUFFIX}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
)
|
||||
business = _api(
|
||||
"POST",
|
||||
"/api/v1/business/businesses",
|
||||
token,
|
||||
{
|
||||
"customer_id": customer["id"],
|
||||
"business_date": f"{MONTH}-01",
|
||||
"owner_personnel_id": person["id"],
|
||||
"items": [
|
||||
{
|
||||
"product_name": f"e2e_报表产品_{SUFFIX}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
"cost_amount": "3000.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
from datetime import date, timedelta
|
||||
|
||||
ship_date = (date.today() - timedelta(days=60)).isoformat()
|
||||
shipment = _api(
|
||||
"POST",
|
||||
"/api/v1/business/shipments",
|
||||
token,
|
||||
{
|
||||
"business_id": business["id"],
|
||||
"ship_date": ship_date,
|
||||
"items": [{"product_name": "e2e_报表发货", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
)
|
||||
receipt = _api(
|
||||
"POST",
|
||||
"/api/v1/business/receipts",
|
||||
token,
|
||||
{
|
||||
"business_id": business["id"],
|
||||
"receipt_date": date.today().isoformat(),
|
||||
"amount": "10000.00",
|
||||
"shipment_id": shipment["id"],
|
||||
},
|
||||
)
|
||||
_api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token)
|
||||
print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算")
|
||||
return {
|
||||
"month": MONTH,
|
||||
"business_no": business["business_no"],
|
||||
"personnel_name": person["personnel_name"],
|
||||
"manager_name": manager["personnel_name"],
|
||||
}
|
||||
|
||||
|
||||
def step_personal_detail(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/personal-detail", wait_until="networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
if current_path(page) != "/reports/personal-detail":
|
||||
fail("导航", f"未进入个人绩效明细页,当前 {page.url}")
|
||||
|
||||
# 月份筛选:Element Plus 月份选择器可直接键入 YYYY-MM
|
||||
month_input = page.locator(".filters input").first
|
||||
month_input.fill(ctx["month"])
|
||||
month_input.press("Enter")
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
if row.count() == 0:
|
||||
fail("个人明细", f"未找到业务 {ctx['business_no']} 的明细行")
|
||||
print("✓ 3. 个人明细 列表命中本次核算业务")
|
||||
|
||||
with page.expect_download(timeout=20000) as download_info:
|
||||
page.locator("button:has-text('导出 Excel')").first.click()
|
||||
download = download_info.value
|
||||
if not download.suggested_filename.endswith(".xlsx"):
|
||||
fail("导出", f"下载文件名非 xlsx:{download.suggested_filename}")
|
||||
print(f"✓ 4. 导出下载 {download.suggested_filename}")
|
||||
|
||||
|
||||
def step_department_summary(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/department-summary", wait_until="networkidle")
|
||||
page.wait_for_timeout(900)
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["manager_name"]).first
|
||||
if row.count() == 0:
|
||||
fail("部门汇总", f"未找到经理 {ctx['manager_name']} 的团队行")
|
||||
if page.locator(".company-card").count() == 0:
|
||||
fail("部门汇总", "全公司 5% 卡片未渲染")
|
||||
print("✓ 5. 部门汇总 团队行与全公司卡片可见")
|
||||
|
||||
|
||||
def step_business_stats(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/business-stats", wait_until="networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
keyword = page.locator(".filters input").first
|
||||
keyword.fill(ctx["business_no"])
|
||||
keyword.press("Enter")
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
if row.count() == 0:
|
||||
fail("业务统计", f"未找到业务 {ctx['business_no']}")
|
||||
print("✓ 6. 业务统计 关键字筛选命中")
|
||||
|
||||
|
||||
def step_payment_stats(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/payment-stats", wait_until="networkidle")
|
||||
page.wait_for_timeout(900)
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
rows = page.locator(".el-table__row")
|
||||
if rows.count() != 8:
|
||||
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
|
||||
bucket = page.locator(".el-table__row", has_text="61-90 天").first
|
||||
if bucket.count() == 0:
|
||||
fail("回款统计", "未找到 61-90 天区间行")
|
||||
bucket_text = bucket.inner_text()
|
||||
if "10000.00" not in bucket_text:
|
||||
fail("回款统计", f"61-90 天桶回款金额不符:{bucket_text[:120]}")
|
||||
print("✓ 7. 回款统计 8 档区间与目标桶金额正确")
|
||||
|
||||
|
||||
def step_print_view(page: Page, ctx: dict) -> None:
|
||||
page.add_init_script("window.print = () => { window.__printed = true }")
|
||||
page.goto(
|
||||
f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}",
|
||||
wait_until="networkidle",
|
||||
)
|
||||
page.wait_for_timeout(1200)
|
||||
if page.locator(".sheet-table").count() == 0:
|
||||
fail("打印视图", "报表表格未渲染")
|
||||
text = page.locator(".print-page").inner_text()
|
||||
if "财务复核" not in text or "领导签字" not in text:
|
||||
fail("打印视图", "签字栏缺失")
|
||||
page.locator("button:has-text('打印')").first.click()
|
||||
page.wait_for_timeout(400)
|
||||
if page.evaluate("window.__printed") is not True:
|
||||
fail("打印视图", "window.print 未被调用")
|
||||
print("✓ 8. 打印视图 表格/签字栏/打印调用均正常")
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
collect_console_errors(page, console_errors)
|
||||
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||
if not admin_password:
|
||||
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||
|
||||
step_login(page, admin_password)
|
||||
ctx = step_setup_via_api(page)
|
||||
step_personal_detail(page, ctx)
|
||||
step_department_summary(page, ctx)
|
||||
step_business_stats(page, ctx)
|
||||
step_payment_stats(page, ctx)
|
||||
step_print_view(page, ctx)
|
||||
|
||||
if console_errors:
|
||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1000})
|
||||
page.set_default_timeout(15000)
|
||||
try:
|
||||
run(page)
|
||||
except E2EFailure as exc:
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
print("\n全部通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,228 @@
|
||||
import service, { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 报表分页:标准分页四字段 + 全筛选合计(summary 非当前页合计) */
|
||||
export interface ReportSummary {
|
||||
row_count: number
|
||||
performance_total?: string | null
|
||||
payable_total?: string | null
|
||||
converted_profit_total?: string | null
|
||||
ship_total?: string | null
|
||||
received_total?: string | null
|
||||
sale_total?: string | null
|
||||
}
|
||||
|
||||
export interface ReportPage<T> extends PageData<T> {
|
||||
summary: ReportSummary
|
||||
}
|
||||
|
||||
/** 个人绩效明细行(金额/比例一律字符串,前端不做数值运算) */
|
||||
export interface PersonalDetailRow {
|
||||
ledger_id: number
|
||||
business_id: number
|
||||
business_no: string
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
role_name: string
|
||||
customer_type: string
|
||||
product_category: string
|
||||
ship_date: string | null
|
||||
receipt_date: string
|
||||
payment_days: number | null
|
||||
rate_text: string
|
||||
weight_r: number | null
|
||||
payment_weight: string | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
converted_profit: string | null
|
||||
is_probation: number
|
||||
missing_cost: number
|
||||
calc_version: number
|
||||
settlement_id: number | null
|
||||
}
|
||||
|
||||
export interface DepartmentMemberRow {
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
is_probation: number
|
||||
performance_sum: string | null
|
||||
payable_sum: string | null
|
||||
adjust_extra: string
|
||||
adjust_deduct: string
|
||||
adjust_net: string
|
||||
net_total: string | null
|
||||
ledger_ids: number[]
|
||||
}
|
||||
|
||||
export interface DepartmentTeamRow {
|
||||
manager_id: number
|
||||
manager_name: string
|
||||
manager_status: number
|
||||
member_count: number
|
||||
team_base: string
|
||||
team_share_amount: string
|
||||
company_share_amount: string
|
||||
company_base_ratio: string
|
||||
adjust_extra: string
|
||||
adjust_deduct: string
|
||||
adjust_net: string
|
||||
net_total: string
|
||||
frozen_amount: string | null
|
||||
aggregate_ledger_id: number | null
|
||||
members: DepartmentMemberRow[]
|
||||
}
|
||||
|
||||
export interface DepartmentSummary {
|
||||
settlement_month: string
|
||||
params: {
|
||||
team_text: string
|
||||
company_text: string
|
||||
manager_personal_included: number
|
||||
params_as_of: string
|
||||
} | null
|
||||
teams: DepartmentTeamRow[]
|
||||
unassigned: DepartmentMemberRow[]
|
||||
company: {
|
||||
team_base_total: string
|
||||
company_base: string
|
||||
company_share_pct: string
|
||||
company_share_amount: string
|
||||
} | null
|
||||
totals: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BusinessStatsRow {
|
||||
business_id: number
|
||||
business_no: string
|
||||
business_date: string | null
|
||||
status: number
|
||||
customer_name: string | null
|
||||
owner_personnel_name: string | null
|
||||
total_sale_amount: string | null
|
||||
total_ship_amount: string | null
|
||||
total_received_amount: string | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
}
|
||||
|
||||
export interface BucketRow {
|
||||
code: string
|
||||
label: string
|
||||
min_days: number | null
|
||||
max_days: number | null
|
||||
receipt_amount: string | null
|
||||
receipt_count: number
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
ledger_count: number
|
||||
}
|
||||
|
||||
export interface GroupRow {
|
||||
code: string
|
||||
label: string
|
||||
receipt_amount: string | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
ledger_count: number
|
||||
}
|
||||
|
||||
export interface PaymentStats {
|
||||
settlement_month: string | null
|
||||
buckets: BucketRow[]
|
||||
by_customer_type: GroupRow[]
|
||||
by_product_category: GroupRow[]
|
||||
totals: Record<string, string | number>
|
||||
}
|
||||
|
||||
export interface PaymentDetailRow {
|
||||
ledger_id: number
|
||||
receipt_id: number | null
|
||||
receipt_batch_no: string | null
|
||||
amount: string | null
|
||||
business_id: number
|
||||
business_no: string
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
customer_type: string
|
||||
product_category: string
|
||||
ship_date: string | null
|
||||
receipt_date: string
|
||||
payment_days: number | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
}
|
||||
|
||||
/** 报表查询通用筛选(各页按需取用) */
|
||||
export interface ReportFilterParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
settlement_month?: string
|
||||
personnel_id?: number
|
||||
customer_type?: string
|
||||
product_category?: string
|
||||
role_code?: string
|
||||
manager_id?: number
|
||||
keyword?: string
|
||||
customer_id?: number
|
||||
owner_personnel_id?: number
|
||||
status?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
bucket?: string
|
||||
}
|
||||
|
||||
export function listPersonalDetail(
|
||||
params: ReportFilterParams,
|
||||
): Promise<ReportPage<PersonalDetailRow>> {
|
||||
return request({ url: '/v1/reports/personal-detail', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getDepartmentSummary(params: ReportFilterParams): Promise<DepartmentSummary> {
|
||||
return request({ url: '/v1/reports/department-summary', method: 'get', params })
|
||||
}
|
||||
|
||||
export function listBusinessStats(
|
||||
params: ReportFilterParams,
|
||||
): Promise<ReportPage<BusinessStatsRow>> {
|
||||
return request({ url: '/v1/reports/business-stats', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getPaymentStats(params: ReportFilterParams): Promise<PaymentStats> {
|
||||
return request({ url: '/v1/reports/payment-stats', method: 'get', params })
|
||||
}
|
||||
|
||||
export function listPaymentDetail(
|
||||
params: ReportFilterParams,
|
||||
): Promise<ReportPage<PaymentDetailRow>> {
|
||||
return request({ url: '/v1/reports/payment-detail', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getPaymentDayBuckets(): Promise<
|
||||
{ code: string; label: string; min_days: number | null; max_days: number | null }[]
|
||||
> {
|
||||
return request({ url: '/v1/reports/payment-day-buckets', method: 'get' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出报表(文件流)。走 axios 实例(带令牌)而非 request 封装:响应是
|
||||
* xlsx 文件流,不是统一信封,拦截器已对 responseType=blob 直通。
|
||||
* 服务端文件名是 ASCII(HTTP 头 latin-1 限制),中文名由调用方 link.download 指定。
|
||||
*/
|
||||
export async function exportReport(
|
||||
code: 'personal-detail' | 'department-summary' | 'business-stats' | 'payment-stats',
|
||||
params: ReportFilterParams,
|
||||
): Promise<Blob> {
|
||||
const response = await service.get(`/v1/reports/${code}/export`, {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data as Blob
|
||||
}
|
||||
|
||||
/** 产品类别展示名:报表需额外覆盖「混合业务」(台账行可为 mixed) */
|
||||
export function reportCategoryLabel(code: string): string {
|
||||
if (code === 'mixed') return '混合业务'
|
||||
return { municipal_water: '市政水利', rail_transit: '轨道交通' }[code] ?? code
|
||||
}
|
||||
@@ -158,8 +158,66 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/settlements/index.vue'),
|
||||
meta: { title: '月度结算', group: '绩效工资', order: 18 },
|
||||
},
|
||||
// 报表统计组(M4 ⑤):FR-030/031 限领导/财务/管理员;admin 须显式
|
||||
// 列入 allowedRoles(项目既定规则,admin 不天然放行)
|
||||
{
|
||||
path: '/reports/personal-detail',
|
||||
name: 'reports-personal-detail',
|
||||
component: () => import('@/views/reports/personal-detail/index.vue'),
|
||||
meta: {
|
||||
title: '个人绩效明细',
|
||||
group: '报表统计',
|
||||
order: 19,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/reports/department-summary',
|
||||
name: 'reports-department-summary',
|
||||
component: () => import('@/views/reports/department-summary/index.vue'),
|
||||
meta: {
|
||||
title: '部门绩效汇总',
|
||||
group: '报表统计',
|
||||
order: 20,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/reports/business-stats',
|
||||
name: 'reports-business-stats',
|
||||
component: () => import('@/views/reports/business-stats/index.vue'),
|
||||
meta: {
|
||||
title: '业务统计',
|
||||
group: '报表统计',
|
||||
order: 21,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/reports/payment-stats',
|
||||
name: 'reports-payment-stats',
|
||||
component: () => import('@/views/reports/payment-stats/index.vue'),
|
||||
meta: {
|
||||
title: '回款统计',
|
||||
group: '报表统计',
|
||||
order: 22,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// 打印视图独立于 BasicLayout(不挂侧栏/顶栏,浏览器打印只出报表本身);
|
||||
// menu:false 不进菜单,但 allowedRoles 守卫照常生效
|
||||
path: '/reports/print/:code',
|
||||
name: 'report-print',
|
||||
component: () => import('@/views/reports/print/index.vue'),
|
||||
meta: {
|
||||
title: '报表打印',
|
||||
menu: false,
|
||||
allowedRoles: [ROLE_ADMIN, 'finance', 'leader'],
|
||||
},
|
||||
},
|
||||
// 未匹配路由回首页;若未登录会被守卫拦到登录页
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/dashboard' },
|
||||
]
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<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],
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(params: ReportFilterParams) {
|
||||
const data = await reportApi.listBusinessStats(params)
|
||||
summary.value = data.summary
|
||||
return data
|
||||
}
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<BusinessStatsRow, ReportFilterParams>(fetchPage, getFilters)
|
||||
|
||||
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
|
||||
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>
|
||||
@@ -0,0 +1,296 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { listPersonnel } from '@/api/personnel'
|
||||
import * as reportApi from '@/api/reports'
|
||||
import type { DepartmentSummary } from '@/api/reports'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 部门绩效工资汇总表(FR-030):按经理团队/月份汇总。
|
||||
*
|
||||
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
|
||||
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
|
||||
* 冻结额(frozen_amount)对账。全公司 5% 部分单列——各团队行展示的
|
||||
* 5% 金额是同一基数的名义值,不能相加。
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const filters = reactive({
|
||||
settlement_month: currentMonth(),
|
||||
manager_id: undefined as number | undefined,
|
||||
})
|
||||
|
||||
const { options: managerOptions, load: loadManagers } = useDropdownOptions(() =>
|
||||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||||
),
|
||||
)
|
||||
void loadManagers()
|
||||
|
||||
const data = ref<DepartmentSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
|
||||
function currentMonth(): string {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (!filters.settlement_month) {
|
||||
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await reportApi.getDepartmentSummary({
|
||||
settlement_month: filters.settlement_month,
|
||||
manager_id: filters.manager_id,
|
||||
})
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
async function doExport(): Promise<void> {
|
||||
if (!data.value) {
|
||||
ElMessage.warning('请先查询')
|
||||
return
|
||||
}
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await reportApi.exportReport('department-summary', {
|
||||
settlement_month: filters.settlement_month,
|
||||
manager_id: filters.manager_id,
|
||||
})
|
||||
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> = { settlement_month: filters.settlement_month }
|
||||
if (filters.manager_id) query.manager_id = String(filters.manager_id)
|
||||
const route = router.resolve({
|
||||
name: 'report-print',
|
||||
params: { code: 'department-summary' },
|
||||
query,
|
||||
})
|
||||
window.open(route.href, '_blank')
|
||||
}
|
||||
|
||||
function openPersonalDetail(member: { personnel_id: number }): void {
|
||||
void router.push({
|
||||
name: 'reports-personal-detail',
|
||||
query: {
|
||||
personnel_id: String(member.personnel_id),
|
||||
settlement_month: filters.settlement_month,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
const now = new Date()
|
||||
return `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(
|
||||
now.getDate(),
|
||||
).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function pct(value: string): string {
|
||||
return `${(Number(value) * 100).toFixed(2)}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<el-form-item label="统计月份" required>
|
||||
<el-date-picker
|
||||
v-model="filters.settlement_month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="选择月份"
|
||||
:clearable="false"
|
||||
style="width: 140px"
|
||||
@change="load"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="经理">
|
||||
<el-select
|
||||
v-model="filters.manager_id"
|
||||
placeholder="全部经理"
|
||||
clearable
|
||||
filterable
|
||||
style="width: 160px"
|
||||
@change="load"
|
||||
>
|
||||
<el-option
|
||||
v-for="option in managerOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="load">查询</el-button>
|
||||
<el-button :loading="exporting" @click="doExport">导出 Excel</el-button>
|
||||
<el-button @click="doPrint">打印</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<div v-if="data?.params" class="params-hint">
|
||||
分成参数:团队 {{ data.params.team_text }}% | 全公司 {{ data.params.company_text }}% |
|
||||
经理个人户{{ data.params.manager_personal_included === 1 ? '计入' : '不计入' }}基数 |
|
||||
取值时点 {{ data.params.params_as_of }}
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="company-card">
|
||||
<span class="company-title">全公司 5% 部分(单列)</span>
|
||||
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</span>
|
||||
<span>5% 分成:{{ data?.company?.company_share_amount ?? '—' }}</span>
|
||||
<span class="company-note">
|
||||
各团队行的 5% 金额基于同一全公司基数,不可跨团队相加
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<el-table v-loading="loading" :data="data?.teams ?? []" row-key="manager_id" stripe>
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<div class="member-wrap">
|
||||
<el-table :data="row.members" size="small" stripe>
|
||||
<el-table-column prop="personnel_name" label="成员" width="120" />
|
||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||
<el-table-column prop="performance_sum" label="绩效金额" width="120" align="right" />
|
||||
<el-table-column prop="payable_sum" label="应付绩效" width="120" align="right" />
|
||||
<el-table-column label="调整补发" width="110" align="right">
|
||||
<template #default="{ row: member }">{{ member.adjust_extra }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="调整扣减" width="110" align="right">
|
||||
<template #default="{ row: member }">{{ member.adjust_deduct }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
|
||||
<el-table-column label="操作" width="100" fixed="right">
|
||||
<template #default="{ row: member }">
|
||||
<el-button link type="primary" @click="openPersonalDetail(member)">
|
||||
查看明细
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<el-empty v-if="row.members.length === 0" description="该经理本月无团队成员业绩" />
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="manager_name" label="经理团队" width="130">
|
||||
<template #default="{ row }">
|
||||
{{ row.manager_name }}
|
||||
<el-tag v-if="row.manager_status === 0" size="small" type="info" disable-transitions>
|
||||
离职
|
||||
</el-tag>
|
||||
</template>
|
||||
</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 label="占全公司比" width="110" align="right">
|
||||
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="adjust_net" label="调整净额" width="110" align="right" />
|
||||
<el-table-column prop="net_total" label="净合计" width="120" align="right" />
|
||||
<el-table-column label="月结冻结额" width="130" align="right">
|
||||
<template #default="{ row }">
|
||||
<span v-if="row.frozen_amount !== null" class="frozen">{{ row.frozen_amount }}</span>
|
||||
<span v-else class="frozen-none">未生成月结</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="data && data.unassigned.length" class="unassigned">
|
||||
<h4>未纳入团队基数(无经理 / 非营销经理上级的下属)</h4>
|
||||
<el-table :data="data.unassigned" size="small" stripe>
|
||||
<el-table-column prop="personnel_name" label="人员" width="130" />
|
||||
<el-table-column prop="role_code" label="岗位" width="120" />
|
||||
<el-table-column prop="performance_sum" label="绩效金额" width="130" align="right" />
|
||||
<el-table-column prop="net_total" label="净合计" width="130" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.params-hint {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: var(--ys-ink-2);
|
||||
background: var(--ys-mist);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.company-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
margin-bottom: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--ys-indigo-wash);
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.company-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.company-note {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.member-wrap {
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
.frozen {
|
||||
color: var(--ys-ink-2);
|
||||
}
|
||||
|
||||
.frozen-none {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.unassigned {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.unassigned h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,310 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
|
||||
import * as reportApi from '@/api/reports'
|
||||
import type { BucketRow, PaymentDetailRow, PaymentStats } from '@/api/reports'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 回款情况统计表(FR-031 之二):按回款天数区间/客户类型/产品类别统计。
|
||||
*
|
||||
* 区间档位由后端下发(禁止前端硬编码);空桶恒在且显示 0。回款金额侧
|
||||
* 按回款单去重(二次分摊拆行不会让金额翻倍)。进度登记回款无台账行,
|
||||
* 不在本表口径内(页面已注明)。
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const filters = reactive({
|
||||
settlement_month: '',
|
||||
customer_type: '',
|
||||
product_category: '',
|
||||
})
|
||||
const dateRange = ref<[string, string] | null>(null)
|
||||
|
||||
const data = ref<PaymentStats | null>(null)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
|
||||
function getFilters() {
|
||||
return {
|
||||
settlement_month: filters.settlement_month || undefined,
|
||||
customer_type: filters.customer_type || undefined,
|
||||
product_category: filters.product_category || undefined,
|
||||
date_from: dateRange.value?.[0],
|
||||
date_to: dateRange.value?.[1],
|
||||
}
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await reportApi.getPaymentStats(getFilters())
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
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()
|
||||
}
|
||||
|
||||
async function fetchDrill(): Promise<void> {
|
||||
if (!drill.bucket) return
|
||||
drill.loading = true
|
||||
try {
|
||||
const page = await reportApi.listPaymentDetail({
|
||||
...getFilters(),
|
||||
bucket: drill.bucket,
|
||||
pageNum: drill.pageNum,
|
||||
pageSize: drill.pageSize,
|
||||
})
|
||||
drill.rows = page.list
|
||||
drill.total = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
drill.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDrillPage(page: number): void {
|
||||
drill.pageNum = page
|
||||
void fetchDrill()
|
||||
}
|
||||
|
||||
function onDrillSize(size: number): void {
|
||||
drill.pageSize = size
|
||||
drill.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||||
void fetchDrill()
|
||||
}
|
||||
|
||||
async function doExport(): Promise<void> {
|
||||
exporting.value = true
|
||||
try {
|
||||
const blob = await reportApi.exportReport('payment-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: 'payment-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-date-picker
|
||||
v-model="filters.settlement_month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="全部月份"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
/>
|
||||
</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 label="客户类型">
|
||||
<el-select v-model="filters.customer_type" placeholder="全部" clearable style="width: 130px">
|
||||
<el-option
|
||||
v-for="option in CUSTOMER_TYPE_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类别">
|
||||
<el-select
|
||||
v-model="filters.product_category"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option label="市政水利" value="municipal_water" />
|
||||
<el-option label="轨道交通" value="rail_transit" />
|
||||
<el-option label="混合业务" value="mixed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" :loading="loading" @click="load">查询</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">
|
||||
统计口径:已计提回款(有台账行)的金额与绩效;进度登记回款不计入。空区间显示 0。
|
||||
</div>
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="data?.buckets ?? []"
|
||||
row-key="code"
|
||||
stripe
|
||||
@expand-change="loadDrill"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default>
|
||||
<div class="drill-wrap">
|
||||
<el-table v-loading="drill.loading" :data="drill.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" />
|
||||
<el-table-column prop="amount" label="回款金额" width="120" align="right" />
|
||||
<el-table-column prop="personnel_name" label="归属人员" width="110" />
|
||||
<el-table-column prop="payment_days" label="天数" width="80" align="right" />
|
||||
<el-table-column label="客户类型" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ customerTypeLabel(row.customer_type) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="产品类别" width="100">
|
||||
<template #default="{ row }">
|
||||
{{ reportApi.reportCategoryLabel(row.product_category) }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<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"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="label" label="回款天数区间" min-width="150" />
|
||||
<el-table-column prop="receipt_amount" label="回款金额" width="140" align="right" />
|
||||
<el-table-column prop="receipt_count" label="回款笔数" width="100" align="right" />
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||||
<el-table-column prop="payable_amount" label="应付绩效" width="130" align="right" />
|
||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||||
</el-table>
|
||||
|
||||
<div v-if="data" class="summary-bar">
|
||||
合计:回款 {{ data.totals.receipt_amount ?? '0.00' }} | 笔数
|
||||
{{ data.totals.receipt_count ?? 0 }} | 绩效 {{ data.totals.performance_amount ?? '0.00' }}
|
||||
| 应付 {{ data.totals.payable_amount ?? '0.00' }}
|
||||
</div>
|
||||
|
||||
<div v-if="data" class="dimension-grid">
|
||||
<div>
|
||||
<h4>客户类型对比</h4>
|
||||
<el-table :data="data.by_customer_type" size="small" stripe>
|
||||
<el-table-column prop="label" label="客户类型" width="120" />
|
||||
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
<div>
|
||||
<h4>产品类别对比</h4>
|
||||
<el-table :data="data.by_product_category" size="small" stripe>
|
||||
<el-table-column prop="label" label="产品类别" width="120" />
|
||||
<el-table-column prop="receipt_amount" label="回款金额" width="130" align="right" />
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="130" align="right" />
|
||||
<el-table-column prop="ledger_count" label="台账行数" width="100" align="right" />
|
||||
</el-table>
|
||||
</div>
|
||||
</div>
|
||||
</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;
|
||||
}
|
||||
|
||||
.summary-bar {
|
||||
margin-top: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 13px;
|
||||
background: var(--ys-indigo-wash);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.dimension-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(420px, 1fr));
|
||||
gap: 20px;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.dimension-grid h4 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,304 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import * as calcApi from '@/api/calc'
|
||||
import type { LedgerRow } from '@/api/calc'
|
||||
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 AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 个人绩效工资明细表(FR-029):按人员/月份/客户类型/产品类别/岗位查明细。
|
||||
*
|
||||
* 口径与台账一致(后端同源查询);页脚合计是**全筛选结果**之和,不是
|
||||
* 当前页之和(页面已注明)。行展开看计算快照(按台账 id 懒加载)。
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const filters = reactive({
|
||||
settlement_month: '',
|
||||
personnel_id: undefined as number | undefined,
|
||||
customer_type: '',
|
||||
product_category: '',
|
||||
role_code: '',
|
||||
})
|
||||
|
||||
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 })),
|
||||
),
|
||||
)
|
||||
void loadPersonnel()
|
||||
void loadRoles()
|
||||
|
||||
const summary = ref<ReportSummary | null>(null)
|
||||
|
||||
function getFilters(): ReportFilterParams {
|
||||
return {
|
||||
// axios 自动剔除 undefined;空串需显式转换,否则后端收到空字符串会精确匹配空值
|
||||
settlement_month: filters.settlement_month || undefined,
|
||||
personnel_id: filters.personnel_id,
|
||||
customer_type: filters.customer_type || undefined,
|
||||
product_category: filters.product_category || undefined,
|
||||
role_code: filters.role_code || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(params: ReportFilterParams) {
|
||||
const data = await reportApi.listPersonalDetail(params)
|
||||
summary.value = data.summary
|
||||
return data
|
||||
}
|
||||
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<PersonalDetailRow, ReportFilterParams>(fetchPage, getFilters)
|
||||
|
||||
// 进页即加载(与月结/台账页修复后的行为一致;筛选条件默认为「全部」)
|
||||
onMounted(search)
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.settlement_month = ''
|
||||
filters.personnel_id = undefined
|
||||
filters.customer_type = ''
|
||||
filters.product_category = ''
|
||||
filters.role_code = ''
|
||||
search()
|
||||
}
|
||||
|
||||
/** 快照懒加载缓存:同一台账行只取一次 */
|
||||
const snapshots = ref<Record<number, LedgerRow>>({})
|
||||
|
||||
async function loadSnapshot(row: PersonalDetailRow): Promise<void> {
|
||||
if (snapshots.value[row.ledger_id]) return
|
||||
try {
|
||||
const ledger = await calcApi.getLedger(row.ledger_id)
|
||||
snapshots.value = { ...snapshots.value, [row.ledger_id]: ledger }
|
||||
} 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('personal-detail', 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 route = router.resolve({
|
||||
name: 'report-print',
|
||||
params: { code: 'personal-detail' },
|
||||
query: cleanQuery(getFilters()),
|
||||
})
|
||||
window.open(route.href, '_blank')
|
||||
}
|
||||
|
||||
function cleanQuery(params: ReportFilterParams): Record<string, string> {
|
||||
const query: Record<string, string> = {}
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null && value !== '') query[key] = String(value)
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
const now = new Date()
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const day = String(now.getDate()).padStart(2, '0')
|
||||
return `${now.getFullYear()}${month}${day}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<el-form inline class="filters" @submit.prevent>
|
||||
<el-form-item label="月份">
|
||||
<el-date-picker
|
||||
v-model="filters.settlement_month"
|
||||
type="month"
|
||||
value-format="YYYY-MM"
|
||||
placeholder="全部月份"
|
||||
clearable
|
||||
style="width: 140px"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="人员">
|
||||
<el-select
|
||||
v-model="filters.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.customer_type" placeholder="全部" clearable style="width: 130px">
|
||||
<el-option
|
||||
v-for="option in CUSTOMER_TYPE_OPTIONS"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="产品类别">
|
||||
<el-select
|
||||
v-model="filters.product_category"
|
||||
placeholder="全部"
|
||||
clearable
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option label="市政水利" value="municipal_water" />
|
||||
<el-option label="轨道交通" value="rail_transit" />
|
||||
<el-option label="混合业务" value="mixed" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="岗位">
|
||||
<el-select v-model="filters.role_code" placeholder="全部" clearable style="width: 130px">
|
||||
<el-option
|
||||
v-for="option in roleOptions"
|
||||
:key="option.value"
|
||||
:label="option.label"
|
||||
:value="option.value"
|
||||
/>
|
||||
</el-select>
|
||||
</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>
|
||||
|
||||
<el-table v-loading="loading" :data="records" stripe @expand-change="loadSnapshot">
|
||||
<el-table-column type="expand">
|
||||
<template #default="{ row }">
|
||||
<pre class="snapshot">{{
|
||||
JSON.stringify(snapshots[row.ledger_id]?.snapshot_json ?? '加载中…', null, 2)
|
||||
}}</pre>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="160" />
|
||||
<el-table-column prop="ship_date" label="发货日" width="105" />
|
||||
<el-table-column prop="receipt_date" label="回款日" width="105" />
|
||||
<el-table-column prop="payment_days" label="回款天数" width="90" align="right" />
|
||||
<el-table-column label="产品类别" width="100">
|
||||
<template #default="{ row }">{{ reportApi.reportCategoryLabel(row.product_category) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="客户类型" width="100">
|
||||
<template #default="{ row }">{{ customerTypeLabel(row.customer_type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="role_name" label="岗位" width="100" />
|
||||
<el-table-column prop="rate_text" label="计提比例" width="110" />
|
||||
<el-table-column prop="weight_r" label="支付权重R" width="95" align="right" />
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="110" align="right">
|
||||
<template #default="{ row }">
|
||||
<span :class="{ negative: Number(row.performance_amount) < 0 }">
|
||||
{{ row.performance_amount ?? '—' }}
|
||||
</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="payable_amount" label="应付绩效" width="110" align="right" />
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.is_probation === 1" size="small" type="warning" disable-transitions>
|
||||
试用期
|
||||
</el-tag>
|
||||
<el-tag v-if="row.missing_cost === 1" size="small" type="info" disable-transitions>
|
||||
缺成本
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div v-if="summary" class="summary-bar">
|
||||
全部筛选结果:{{ summary.row_count }} 行 | 绩效合计 {{ summary.performance_total ?? '0.00' }}
|
||||
| 应付合计 {{ summary.payable_total ?? '0.00' }}
|
||||
<span class="summary-note">(合计为全部筛选结果,非当前页)</span>
|
||||
</div>
|
||||
|
||||
<!-- AppPagination 只派发 page-change/size-change(无 update:pageNum),
|
||||
不能用 v-model 或 @change——翻了页不会加载 -->
|
||||
<AppPagination
|
||||
:total="total"
|
||||
:page-num="pageNum"
|
||||
:page-size="pageSize"
|
||||
@page-change="onPageChange"
|
||||
@size-change="onSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filters {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.snapshot {
|
||||
margin: 0;
|
||||
padding: 10px;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
background: #f4f5f8;
|
||||
border-radius: 6px;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: #c45656;
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,411 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
import { businessStatusLabel } from '@/api/businesses'
|
||||
import { customerTypeLabel } from '@/api/customers'
|
||||
import * as reportApi from '@/api/reports'
|
||||
import type {
|
||||
BusinessStatsRow,
|
||||
DepartmentMemberRow,
|
||||
PersonalDetailRow,
|
||||
ReportFilterParams,
|
||||
} from '@/api/reports'
|
||||
import { getPublicSettings } from '@/api/settings'
|
||||
import { handledError } from '@/utils/error'
|
||||
|
||||
/**
|
||||
* 报表打印视图(FR-032):独立顶层路由,不挂 BasicLayout——浏览器直接
|
||||
* 打印不会带出侧栏/顶栏。PDF 由打印对话框「另存为 PDF」覆盖,不引入
|
||||
* 前端 PDF 依赖。
|
||||
*
|
||||
* 页眉公司信息走 public 端点(财务/领导非管理员,拿不到 /system/settings)。
|
||||
* 分页接口循环翻页取全量(pageSize 上限 200),超过打印上限截断并提示。
|
||||
*/
|
||||
|
||||
const route = useRoute()
|
||||
const code = computed(() => String(route.params.code))
|
||||
const query = computed(() => route.query as Record<string, string>)
|
||||
/** 路由 query 转为报表查询参数(值均为字符串,数字字段由后端强转) */
|
||||
const queryParams = computed(() => query.value as unknown as ReportFilterParams)
|
||||
|
||||
const PRINT_MAX_ROWS = 2000
|
||||
const PAGE_SIZE = 200
|
||||
|
||||
const companyName = ref('衍水集团')
|
||||
const logoUrl = ref<string | null>(null)
|
||||
const title = ref('')
|
||||
// 表格行是四类报表的并集,模板按报表编码取用各自字段;用宽松记录类型
|
||||
// 避免为四种行形态各写一套模板 props
|
||||
const rows = ref<Record<string, any>[]>([])
|
||||
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 filterText = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
settlement_month: '统计月份',
|
||||
personnel_id: '人员ID',
|
||||
customer_type: '客户类型',
|
||||
product_category: '产品类别',
|
||||
role_code: '岗位',
|
||||
manager_id: '经理ID',
|
||||
keyword: '关键字',
|
||||
customer_id: '客户ID',
|
||||
owner_personnel_id: '归属人ID',
|
||||
status: '状态',
|
||||
date_from: '日期起',
|
||||
date_to: '日期止',
|
||||
}
|
||||
const parts = Object.entries(query.value)
|
||||
.filter(([, value]) => value !== undefined && value !== '')
|
||||
.map(([key, value]) => `${labels[key] ?? key}:${value}`)
|
||||
return parts.join(' | ') || '全部'
|
||||
})
|
||||
|
||||
async function fetchAll<T>(
|
||||
fetcher: (params: ReportFilterParams) => Promise<{ list: T[]; total: number }>,
|
||||
params: ReportFilterParams,
|
||||
): Promise<{ list: T[]; truncated: boolean }> {
|
||||
const collected: T[] = []
|
||||
let pageNum = 1
|
||||
while (collected.length < PRINT_MAX_ROWS) {
|
||||
const data = await fetcher({ ...params, pageNum, pageSize: PAGE_SIZE })
|
||||
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 }
|
||||
}
|
||||
|
||||
function personalRow(row: PersonalDetailRow): Record<string, any> {
|
||||
return {
|
||||
...row,
|
||||
product_category: reportApi.reportCategoryLabel(row.product_category),
|
||||
customer_type: customerTypeLabel(row.customer_type),
|
||||
}
|
||||
}
|
||||
|
||||
function businessRow(row: BusinessStatsRow): Record<string, any> {
|
||||
return { ...row, status: businessStatusLabel(row.status) }
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
loading.value = true
|
||||
try {
|
||||
const settings = await getPublicSettings()
|
||||
companyName.value = settings.company_name || '衍水集团'
|
||||
logoUrl.value = settings.logo_url || null
|
||||
|
||||
if (code.value === 'personal-detail') {
|
||||
title.value = '个人绩效工资明细表'
|
||||
const data = await fetchAll(
|
||||
(params) => reportApi.listPersonalDetail(params),
|
||||
queryParams.value,
|
||||
)
|
||||
rows.value = (data.list as PersonalDetailRow[]).map(personalRow)
|
||||
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,
|
||||
})),
|
||||
)
|
||||
} else if (code.value === 'business-stats') {
|
||||
title.value = '业务统计表'
|
||||
const data = await fetchAll(
|
||||
(params) => reportApi.listBusinessStats(params),
|
||||
queryParams.value,
|
||||
)
|
||||
rows.value = (data.list as BusinessStatsRow[]).map(businessRow)
|
||||
truncated.value = data.truncated
|
||||
} else {
|
||||
title.value = '回款情况统计表'
|
||||
const data = await reportApi.getPaymentStats(queryParams.value)
|
||||
buckets.value = data.buckets as unknown as Record<string, unknown>[]
|
||||
groups.value = [
|
||||
{ title: '客户类型对比', rows: data.by_customer_type as unknown as Record<string, unknown>[] },
|
||||
{
|
||||
title: '产品类别对比',
|
||||
rows: data.by_product_category as unknown as Record<string, unknown>[],
|
||||
},
|
||||
]
|
||||
}
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
printedAt.value = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
function doPrint(): void {
|
||||
window.print()
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="print-page" v-loading="loading">
|
||||
<div class="no-print toolbar">
|
||||
<el-button type="primary" @click="doPrint">打印 / 另存为 PDF</el-button>
|
||||
<span class="toolbar-hint">在打印对话框选择「另存为 PDF」即可导出 PDF</span>
|
||||
</div>
|
||||
|
||||
<header class="sheet-header">
|
||||
<img v-if="logoUrl" :src="logoUrl" class="logo" alt="公司 logo" />
|
||||
<div class="company">{{ companyName }}</div>
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<div class="meta">
|
||||
<span>筛选条件:{{ filterText }}</span>
|
||||
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.0</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="truncated" class="truncated">
|
||||
数据量超过打印上限(2000 行),仅打印前 2000 行;请缩小筛选范围后重新打印。
|
||||
</div>
|
||||
|
||||
<template v-if="code === 'personal-detail'">
|
||||
<table class="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>合同编号</th><th>发货日</th><th>回款日</th><th>回款天数</th>
|
||||
<th>产品类别</th><th>客户类型</th><th>岗位</th><th>计提比例</th>
|
||||
<th>支付权重R</th><th>绩效金额</th><th>应付绩效</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in rows" :key="String(row.ledger_id)">
|
||||
<td>{{ row.business_no }}</td>
|
||||
<td>{{ row.ship_date }}</td>
|
||||
<td>{{ row.receipt_date }}</td>
|
||||
<td class="num">{{ row.payment_days ?? '—' }}</td>
|
||||
<td>{{ row.product_category }}</td>
|
||||
<td>{{ row.customer_type }}</td>
|
||||
<td>{{ row.role_name }}</td>
|
||||
<td>{{ row.rate_text }}</td>
|
||||
<td class="num">{{ row.weight_r ?? '—' }}</td>
|
||||
<td class="num">{{ row.performance_amount ?? '—' }}</td>
|
||||
<td class="num">{{ row.payable_amount ?? '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-else-if="code === 'department-summary'">
|
||||
<table class="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>经理团队</th><th>成员</th><th>岗位</th><th>绩效金额</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>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-else-if="code === 'business-stats'">
|
||||
<table class="sheet-table">
|
||||
<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="row in rows" :key="String(row.business_id)">
|
||||
<td>{{ row.business_no }}</td>
|
||||
<td>{{ row.business_date }}</td>
|
||||
<td>{{ row.customer_name }}</td>
|
||||
<td>{{ row.owner_personnel_name }}</td>
|
||||
<td>{{ row.status }}</td>
|
||||
<td class="num">{{ row.total_sale_amount }}</td>
|
||||
<td class="num">{{ row.total_ship_amount }}</td>
|
||||
<td class="num">{{ row.total_received_amount }}</td>
|
||||
<td class="num">{{ row.performance_amount }}</td>
|
||||
<td class="num">{{ row.payable_amount }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<table class="sheet-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>回款天数区间</th><th>回款金额</th><th>回款笔数</th>
|
||||
<th>绩效金额</th><th>应付绩效</th><th>台账行数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in buckets" :key="String(row.code)">
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.receipt_amount }}</td>
|
||||
<td class="num">{{ row.receipt_count }}</td>
|
||||
<td class="num">{{ row.performance_amount }}</td>
|
||||
<td class="num">{{ row.payable_amount }}</td>
|
||||
<td class="num">{{ row.ledger_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div v-for="group in groups" :key="group.title" class="group-block">
|
||||
<h3>{{ group.title }}</h3>
|
||||
<table class="sheet-table">
|
||||
<thead>
|
||||
<tr><th>维度</th><th>回款金额</th><th>绩效金额</th><th>台账行数</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="row in group.rows" :key="String(row.code)">
|
||||
<td>{{ row.label }}</td>
|
||||
<td class="num">{{ row.receipt_amount }}</td>
|
||||
<td class="num">{{ row.performance_amount }}</td>
|
||||
<td class="num">{{ row.ledger_count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<footer class="signature">
|
||||
<div>财务复核:____________________</div>
|
||||
<div>领导签字:____________________</div>
|
||||
<div>日期:______年__月__日</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.print-page {
|
||||
min-height: 100vh;
|
||||
padding: 24px;
|
||||
background: #fff;
|
||||
color: #1c2333;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
color: var(--ys-ink-3);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sheet-header {
|
||||
margin-bottom: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.logo {
|
||||
max-height: 48px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.company {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 6px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 12px;
|
||||
color: #5b6577;
|
||||
}
|
||||
|
||||
.truncated {
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
color: #8a5a00;
|
||||
background: #fdf6e3;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sheet-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.sheet-table th,
|
||||
.sheet-table td {
|
||||
padding: 6px 8px;
|
||||
border: 1px solid #b0b7c3;
|
||||
}
|
||||
|
||||
.sheet-table th {
|
||||
background: #dde3f0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sheet-table .num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.group-block {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.signature {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-top: 40px;
|
||||
padding-top: 16px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.print-page {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 12mm;
|
||||
}
|
||||
|
||||
.sheet-table thead {
|
||||
display: table-header-group;
|
||||
}
|
||||
|
||||
.sheet-table tr {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user