4 Commits
Author SHA1 Message Date
liuwenxinandClaude Code b7cfdfa50a docs: 需求规格说明书升 V2.1(2026-10-19)
回填 2026-10-16 业务方 24 项澄清答复(12 确认默认 / 4 项与默认不同已落实 / 仍开放三态)、
AC-12 口径修正、二次分摊拆行与月末取参实现口径;FR 42 条 / AC 12 条结构校验通过,
作废口径仅出现在修订对照与作废声明语境。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:34:05 +08:00
liuwenxinandClaude Code 04a1a0d307 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>
2026-10-19 16:33:37 +08:00
liuwenxinandClaude Code 2a1939d2e7 feat(report): 报表统计四表查询与 Excel 签字导出
- 四表接口 /api/v1/reports/*:个人明细(全筛选 summary)、部门汇总(团队基数与月结聚合同源、12.5%/5% 分列、零行经理、冻结对照)、业务统计(排除聚合行)、回款统计(8 档区间恒在、回款金额按 receipt_id 去重防分摊拆行翻倍)
- Excel 导出:公司页眉/财务复核栏/领导签字栏/日期/版本、Decimal 原值 + number_format、文本防公式注入、2 万行超限 400、导出行为入操作日志
- manager_share 共享模块:月结聚合与报表部门汇总同源取参与分桶(薄委托保留测试入口)
- 区间元数据落 core/report_meta(repository 需要类型,避免反向依赖 service 层)
- 准入 require_roles(admin/finance/leader),支持自定义 403 文案

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:33:31 +08:00
liuwenxinandClaude Code cfd2a196f2 style(frontend): 表格操作列统一样式与确认框视觉收敛
- 新增 table-actions.css:操作列 flex 居中、间距交给 gap、破坏性动作前
  加分组竖线、链接按钮悬停加深并加下划线(默认浅靛蓝在表格底色上对比度
  不足);包装 span 改 flex,消除行内盒基线导致的约 1.7px 垂直错位
- 11 个列表页操作列套用统一容器与分隔线,列宽按动作自然宽度调整并居中
- 确认框不再传 type:warning(Element Plus 的实心黄圆图标与正文并排成
  两列,标题行反被留空),图标改由全局样式画在标题行左侧,正文与标题
  文字左对齐;图标为 CSS 伪元素,不进无障碍树

验证:pnpm type-check 绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-19 16:33:06 +08:00
39 changed files with 4497 additions and 294 deletions

No files matched your search

+25
View File
@@ -114,6 +114,21 @@ app/
**登录失败一律 400 或 403,绝不使用 401** —— 前端把 401 一律当作「清 token + 跳登录页」,
若登录接口自身返回 401,用户输错一次密码就会触发「清空 + 跳转 + 报错」的风暴。
### 报表统计接口(M4 ⑤,限管理员/财务/领导)
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| GET | `/api/v1/reports/personal-detail` | 个人绩效工资明细表(FR-029:人员/月份/客户类型/产品类别/岗位筛选;响应带 `summary` 全筛选合计) |
| GET | `/api/v1/reports/department-summary` | 部门绩效工资汇总表(FR-030:月份必填;团队基数与月结聚合行同源;12.5% 与 5% 分列;含成员内联明细、未分配桶、月结冻结对照额) |
| GET | `/api/v1/reports/business-stats` | 业务统计表(FR-031:发货/回款/绩效为业务累计值,不随业务日期筛选变化) |
| GET | `/api/v1/reports/payment-stats` | 回款情况统计表(FR-031:8 档天数区间恒在,空桶为 0) |
| GET | `/api/v1/reports/payment-detail` | 回款区间下钻(`bucket` 必填,未注册编码 400) |
| GET | `/api/v1/reports/payment-day-buckets` | 区间档位元数据(前端下拉唯一来源,禁止前端硬编码) |
| GET | `/api/v1/reports/{code}/export` | Excel 导出(文件流非统一信封;含公司页眉/财务复核栏/领导签字栏;超 2 万行 400;行为入操作日志) |
准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」
待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。
## 常用命令
```bash
@@ -196,6 +211,16 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是
- **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。
- **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。
### 报表口径(M4 ⑤,改动前先读)
- **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。
- **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入;`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。
- **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。
- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块),**12.5% 与 5% 分列**——两项之和才是聚合行金额。
- **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。
- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。
- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;集成测试逐项断言。
## 常见问题
**起服务报 `APP_SECRET_KEY` 相关错误** —— `backend/.env` 未填或不足 32 字符。这是刻意的 fail-fast:空密钥签发 JWT 是安全问题,必须启动即失败。
+5 -2
View File
@@ -119,20 +119,23 @@ AdminUser = Annotated[User, Depends(require_admin)]
LoginUser = Annotated[User, Depends(get_current_user)]
def require_roles(*role_codes: str):
def require_roles(*role_codes: str, message: str = "无权访问审批管理"):
"""要求当前用户具备任一指定角色(依赖工厂)。
与 require_admin 的差别:admin 不是天然放行——传入集合里显式含
ROLE_ADMIN 才可见。用于审批等模块的粗粒度准入;节点级准入仍由
service 按单据状态二次校验(双层:模块可见 ≠ 可操作)。
``message`` 允许各模块替换 403 文案(默认值保持审批模块原文案,
避免既有调用与测试变动)。
:raises ForbiddenError: 已登录但不具备任一指定角色(403)
"""
def _checker(current_user: Annotated[User, Depends(get_current_user)]) -> User:
codes = set(user_repository.get_role_codes(current_user))
if not codes & set(role_codes):
raise ForbiddenError("无权访问审批管理")
raise ForbiddenError(message)
return current_user
return _checker
+3
View File
@@ -16,6 +16,7 @@ from app.api.v1.business import ratings as business_ratings
from app.api.v1.business import receipts as business_receipts
from app.api.v1.business import shipments as business_shipments
from app.api.v1.calc import router as calc_router
from app.api.v1.reports import router as reports_router
from app.api.v1.settlements import router as settlements_router
from app.api.v1.system import approvals as system_approvals
from app.api.v1.system import business_roles as system_business_roles
@@ -65,5 +66,7 @@ v1_router.include_router(business_router)
v1_router.include_router(calc_router)
# 月度结算(M3 ④):/api/v1/settlements/*,读登录即可、写限管理员与营销后勤
v1_router.include_router(settlements_router)
# 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导
v1_router.include_router(reports_router)
api_router.include_router(v1_router)
+178
View File
@@ -0,0 +1,178 @@
"""报表统计接口(M4 ⑤):四张报表只读查询 + Excel 导出。
准入:管理员/财务/领导(FR-030/031 明确;FR-029 的营销后勤「授权范围内」
待权限清单 #6,暂不开放)。前后端双层:前端 allowedRoles 控菜单,本层
require_roles 兜底。
导出返回文件流(非统一信封),沿用参数导出先例:服务端文件名为 ASCII
(HTTP 头按 latin-1 编码,中文名会 UnicodeEncodeError),中文名由前端
``link.download`` 指定。
"""
from datetime import date
from typing import Annotated, Literal
from fastapi import APIRouter, Depends, Path, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from app.api.deps import DbSession, RequestMetaDep, require_roles
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
from app.core.response import ApiResponse, ok
from app.models import User
from app.schemas.report import (
BusinessStatsQuery,
BusinessStatsRow,
DepartmentSummaryOut,
DepartmentSummaryQuery,
PaymentDetailQuery,
PaymentDetailRow,
PaymentStatsOut,
PaymentStatsQuery,
PersonalDetailQuery,
PersonalDetailRow,
ReportPage,
)
from app.services import report_service
router = APIRouter(prefix="/reports", tags=["绩效工资 · 报表统计"])
_ReportViewer = Annotated[
User,
Depends(require_roles(ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问报表统计")),
]
@router.get(
"/personal-detail",
response_model=ApiResponse[ReportPage[PersonalDetailRow]],
summary="个人绩效工资明细表",
)
def personal_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PersonalDetailQuery, Query()],
) -> ApiResponse[ReportPage[PersonalDetailRow]]:
"""按人员/月份/客户类型/产品类别/岗位查明细(口径与台账一致)。"""
return ok(report_service.list_personal_detail(session, query))
@router.get(
"/department-summary",
response_model=ApiResponse[DepartmentSummaryOut],
summary="部门绩效工资汇总表",
)
def department_summary(
session: DbSession,
_: _ReportViewer,
query: Annotated[DepartmentSummaryQuery, Query()],
) -> ApiResponse[DepartmentSummaryOut]:
"""按经理团队/月份汇总(团队基数与月结聚合行同源)。"""
return ok(report_service.department_summary(session, query))
@router.get(
"/business-stats",
response_model=ApiResponse[ReportPage[BusinessStatsRow]],
summary="业务统计表",
)
def business_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[BusinessStatsQuery, Query()],
) -> ApiResponse[ReportPage[BusinessStatsRow]]:
"""按业务查询发货、回款、绩效(回款/绩效为业务累计值)。"""
return ok(report_service.business_stats(session, query))
@router.get(
"/payment-stats",
response_model=ApiResponse[PaymentStatsOut],
summary="回款情况统计表",
)
def payment_stats(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentStatsQuery, Query()],
) -> ApiResponse[PaymentStatsOut]:
"""按回款天数区间/客户类型/产品类别统计(空桶恒在且为 0)。"""
return ok(report_service.payment_stats(session, query))
@router.get(
"/payment-detail",
response_model=ApiResponse[ReportPage[PaymentDetailRow]],
summary="回款区间下钻",
)
def payment_detail(
session: DbSession,
_: _ReportViewer,
query: Annotated[PaymentDetailQuery, Query()],
) -> ApiResponse[ReportPage[PaymentDetailRow]]:
"""区间桶下钻到单笔台账行(含回款金额与批次号)。"""
return ok(report_service.payment_detail(session, query))
@router.get(
"/payment-day-buckets", response_model=ApiResponse[list[dict]], summary="回款天数区间档位"
)
def payment_day_buckets(_: _ReportViewer) -> ApiResponse[list[dict]]:
"""区间档位元数据(前端渲染下拉/表头,禁止前端硬编码档位)。"""
return ok(report_service.get_payment_day_buckets())
class ExportQuery(BaseModel):
"""导出通用筛选参数(按报表编码取用相关字段,未用字段忽略)。"""
settlement_month: str | None = None
personnel_id: int | None = None
customer_type: str | None = None
product_category: str | None = None
role_code: str | None = None
manager_id: int | None = None
keyword: str | None = None
customer_id: int | None = None
owner_personnel_id: int | None = None
status: int | None = None
date_from: date | None = None
date_to: date | None = None
@router.get("/{code}/export", summary="导出报表 Excel")
def export_report(
code: Annotated[
Literal["personal-detail", "department-summary", "business-stats", "payment-stats"],
Path(description="报表编码"),
],
session: DbSession,
current_user: _ReportViewer,
meta: RequestMetaDep,
query: Annotated[ExportQuery, Query()],
) -> StreamingResponse:
"""导出 xlsx(含公司页眉/财务复核栏/领导签字栏),行为入操作日志。"""
content, filename = report_service.export_report(
session,
code,
_build_export_query(code, query),
operator=current_user,
meta=meta,
)
return StreamingResponse(
iter([content]),
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={
"Content-Disposition": (f'attachment; filename="{filename}_{date.today():%Y%m%d}.xlsx"')
},
)
def _build_export_query(code: str, query: ExportQuery):
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。"""
data = query.model_dump()
if code == "personal-detail":
return PersonalDetailQuery(**data)
if code == "department-summary":
return DepartmentSummaryQuery(**data)
if code == "business-stats":
return BusinessStatsQuery(**data)
return PaymentStatsQuery(**data)
File renamed without changes.
+70 -39
View File
@@ -15,6 +15,7 @@
from sqlalchemy import func, select
from sqlalchemy.orm import Session
from app.core.report_meta import DayBucket
from app.models import (
AdjustmentOrder,
Business,
@@ -36,6 +37,7 @@ _ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active
# ==== 个人绩效明细(FR-029)====
def _personal_filters(query: PersonalDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
@@ -65,29 +67,34 @@ def count_personal_detail(session: Session, query: PersonalDetailQuery) -> int:
"""个人绩效明细计数(与 list_personal_detail 同筛选)。"""
return int(
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(*_personal_filters(query))
select(func.count()).select_from(PerformanceLedger).where(*_personal_filters(query))
).scalar_one()
)
def sum_personal_detail(session: Session, query: PersonalDetailQuery) -> dict:
"""全筛选口径合计(分页与合计同条件,避免「页脚合计≠全部结果」)。"""
return session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("performance_total"),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
"converted_profit_total"
),
).where(*_personal_filters(query))
).mappings().one()
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.coalesce(func.sum(PerformanceLedger.converted_profit), 0).label(
"converted_profit_total"
),
).where(*_personal_filters(query))
)
.mappings()
.one()
)
# ==== 部门汇总取数(FR-030)====
def list_month_receipt_rows(session: Session, month: str) -> list[PerformanceLedger]:
"""同月全量生效单笔台账行(部门汇总基数与成员明细的取数)。"""
return list(
@@ -137,6 +144,7 @@ def list_month_settlement(session: Session, month: str) -> MonthlySettlement | N
# ==== 业务统计(FR-031 之一)====
def _business_perf_subquery():
"""业务维度绩效子查询(排除聚合行,防 12.5%/5% 聚合额污染单业务绩效)。"""
return (
@@ -212,23 +220,28 @@ def count_business_stats(session: Session, query: BusinessStatsQuery) -> int:
def sum_business_stats(session: Session, query: BusinessStatsQuery) -> dict:
"""业务统计全筛选合计(业务数/发货/回款/销售额/绩效/应付)。"""
perf_sq = _business_perf_subquery()
return session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
return (
session.execute(
select(
func.count().label("row_count"),
func.coalesce(func.sum(Business.total_ship_amount), 0).label("ship_total"),
func.coalesce(func.sum(Business.total_received_amount), 0).label("received_total"),
func.coalesce(func.sum(Business.total_sale_amount), 0).label("sale_total"),
func.coalesce(func.sum(perf_sq.c.perf), 0).label("performance_total"),
func.coalesce(func.sum(perf_sq.c.payable), 0).label("payable_total"),
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
)
.outerjoin(Customer, Customer.id == Business.customer_id)
.outerjoin(perf_sq, perf_sq.c.business_id == Business.id)
.where(*_business_filters(query))
).mappings().one()
.mappings()
.one()
)
# ==== 回款统计(FR-031 之二)====
def _payment_filters(query: PaymentStatsQuery | PaymentDetailQuery) -> tuple:
filters = [*_ACTIVE_SINGLE]
if query.settlement_month:
@@ -255,9 +268,7 @@ def group_payment_by_days(session: Session, query: PaymentStatsQuery) -> list:
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label(
"payable_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
.where(*_payment_filters(query))
@@ -326,7 +337,9 @@ def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column)
dim_perf = (
select(
dim_column.label("dim"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label("performance_total"),
func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label(
"performance_total"
),
func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"),
func.count().label("ledger_count"),
)
@@ -342,10 +355,7 @@ def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column)
.distinct()
.subquery()
)
perf_rows = {
row.dim: row
for row in session.execute(dim_perf).all()
}
perf_rows = {row.dim: row for row in session.execute(dim_perf).all()}
receipt_rows = session.execute(
select(
dim_receipts.c.dim,
@@ -356,15 +366,36 @@ def _group_by_dimension(session: Session, query: PaymentStatsQuery, dim_column)
).all()
receipt_amounts = {row.dim: row.receipt_amount for row in receipt_rows}
return [
(row.dim, receipt_amounts.get(row.dim), row.performance_total, row.payable_total, row.ledger_count)
(
row.dim,
receipt_amounts.get(row.dim),
row.performance_total,
row.payable_total,
row.ledger_count,
)
for row in perf_rows.values()
]
def _bucket_condition(bucket: DayBucket) -> tuple:
"""区间桶的 days 过滤条件:none 桶 IS NULL,其余按 min/max 闭区间。"""
if bucket.code == "none":
return (PerformanceLedger.payment_days.is_(None),)
conds = []
if bucket.min_days is not None:
conds.append(PerformanceLedger.payment_days >= bucket.min_days)
if bucket.max_days is not None:
conds.append(PerformanceLedger.payment_days <= bucket.max_days)
return tuple(conds)
def list_payment_detail(
session: Session, query: PaymentDetailQuery, *, offset: int, limit: int
session: Session, query: PaymentDetailQuery, bucket: DayBucket, *, offset: int, limit: int
) -> list:
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。"""
"""区间下钻:单笔生效行联回款表(行级展示回款金额与批次号)。
bucket 由服务层解析(未注册编码 400),仓储只按区间拼条件。
"""
from app.models import Receipt
stmt = (
@@ -374,19 +405,19 @@ def list_payment_detail(
Receipt.receipt_batch_no.label("receipt_batch_no"),
)
.outerjoin(Receipt, Receipt.id == PerformanceLedger.receipt_id)
.where(*_payment_filters(query))
.where(*_payment_filters(query), *_bucket_condition(bucket))
)
stmt = stmt.order_by(PerformanceLedger.receipt_date.desc(), PerformanceLedger.id.desc())
stmt = stmt.offset(offset).limit(limit)
return list(session.execute(stmt).all())
def count_payment_detail(session: Session, query: PaymentDetailQuery) -> int:
def count_payment_detail(session: Session, query: PaymentDetailQuery, bucket: DayBucket) -> int:
"""区间下钻计数(与 list_payment_detail 同筛选)。"""
return int(
session.execute(
select(func.count())
.select_from(PerformanceLedger)
.where(*_payment_filters(query))
.where(*_payment_filters(query), *_bucket_condition(bucket))
).scalar_one()
)
+10 -1
View File
@@ -13,6 +13,7 @@ from app.core.response import PageQuery
# ==== 查询入参 ====
class PersonalDetailQuery(PageQuery):
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
@@ -64,13 +65,21 @@ class PaymentDetailQuery(PageQuery):
# ==== 响应出参 ====
class ReportSummary(BaseModel):
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。"""
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
销售额;不涉及的留 None。
"""
row_count: int = Field(default=0, description="筛选结果行数")
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
ship_total: Decimal | None = Field(default=None, description="发货额合计")
received_total: Decimal | None = Field(default=None, description="回款额合计")
sale_total: Decimal | None = Field(default=None, description="销售额合计")
class ReportPage[T](BaseModel):
+118
View File
@@ -0,0 +1,118 @@
"""经理分成共享口径(月结聚合与报表部门汇总的唯一实现)。
从 settlement_service 迁出(行为零变化):经理 12.5%/5% 的取参、团队
分桶、公司基数计算,月结生成与部门汇总报表都要用——抽为单一实现后
「报表汇总 == 月结聚合行」不靠两处代码各自对齐,而是同源函数的直接
结果。分成金额仍走 formula.manager_team_share 纯函数。
"""
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from fractions import Fraction
from sqlalchemy.orm import Session
from app.models import Personnel
@dataclass(frozen=True, slots=True)
class ManagerShareParams:
"""经理分成取参与参数原文(同一次计算共用一份,随月月末时点化)。"""
month: str
param_date: date
team_text: str
company_text: str
team_pct: Fraction
company_pct: Fraction
include_manager_personal: bool
def month_end(month: str) -> date:
"""YYYY-MM → 所属月月末(月结与报表取参锚点)。
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
"""
year, mon = int(month[:4]), int(month[5:7])
return date(year, mon, monthrange(year, mon)[1])
def manager_share_params(session: Session, month: str) -> ManagerShareParams:
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
"""
from app.services.param_service import parse_fraction, resolve_snapshot
param_date = month_end(month)
snapshot = resolve_snapshot(session, param_date)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
return ManagerShareParams(
month=month,
param_date=param_date,
team_text=team_text,
company_text=company_text,
team_pct=parse_fraction(team_text),
company_pct=parse_fraction(company_text),
include_manager_personal=snapshot.get("manager.personal.included") == "1",
)
def add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
def collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""按经理采集团队业绩。
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
按上溯会被跳过漏计)。
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
if include_manager_personal:
add_team_performance(managers, row.personnel_id, row)
continue # 默认不计入聚合基数(#12 参数化)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is not None:
add_team_performance(managers, manager_id, row)
return managers
def company_base_of(ledger_rows: list, include_manager_personal: bool) -> Decimal:
"""全公司基数(5% 的底):默认排除经理个人户行(#12 开关计入时不过滤)。
口径逐行固定,调用方在循环外算一次。
"""
return sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if include_manager_personal or r.customer_type != "manager_personal"
),
Decimal("0"),
)
+309
View File
@@ -0,0 +1,309 @@
"""报表 Excel 模板构建(纯函数,可单测)。
版式(FR-032「导出模板围绕财务复核和领导签字设计」):
公司信息页眉 → 报表标题 → 筛选条件 → 导出时间/人/模板版本 → 表头 → 数据行
→ 合计行 → 财务复核栏 / 领导签字栏 / 日期栏。
精度红线:金额写 ``Decimal``(openpyxl 原生支持,不经 float)+
``number_format='#,##0.00'``;合计在 Python 侧求和后两位收口,**不写
Excel 公式**(避免打开时重算漂移与公式注入面)。文本列以 ``= + - @``
开头时前置单引号,防公式注入。
"""
from dataclasses import dataclass
from datetime import datetime
from decimal import ROUND_HALF_UP, Decimal
from io import BytesIO
from openpyxl import Workbook
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
from openpyxl.utils import get_column_letter
from app.core.report_meta import CUSTOMER_TYPE_LABELS, PRODUCT_CATEGORY_LABELS
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
_THIN = Side(style="thin", color="B0B7C3")
_BORDER = Border(left=_THIN, right=_THIN, top=_THIN, bottom=_THIN)
_TITLE_FONT = Font(size=14, bold=True)
_COMPANY_FONT = Font(size=16, bold=True)
_HEADER_FONT = Font(bold=True)
_AMOUNT_FORMAT = "#,##0.00"
_DATE_FORMAT = "YYYY-MM-DD"
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
BUSINESS_STATUS_LABELS = {1: "进行中", 2: "已完成", 3: "已终止"}
@dataclass(frozen=True, slots=True)
class _Column:
"""列定义:key 取行 dict 字段,kind 决定单元格类型与格式。"""
key: str
title: str
kind: str # text / amount / int / date / customer / category / status
width: float
_COLUMNS: dict[str, tuple[_Column, ...]] = {
"personal-detail": (
_Column("business_no", "合同编号", "text", 20),
_Column("ship_date", "发货日", "date", 12),
_Column("receipt_date", "回款日", "date", 12),
_Column("payment_days", "回款天数", "int", 10),
_Column("product_category", "产品类别", "category", 14),
_Column("customer_type", "客户类型", "customer", 14),
_Column("role_name", "岗位", "text", 12),
_Column("rate_text", "计提比例", "text", 14),
_Column("weight_r", "支付权重R", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"department-summary": (
_Column("manager_name", "经理团队", "text", 16),
_Column("member_name", "成员", "text", 14),
_Column("role_code", "岗位编码", "text", 12),
_Column("performance_sum", "绩效金额", "amount", 14),
_Column("payable_sum", "应付绩效", "amount", 14),
_Column("adjust_net", "调整净额", "amount", 14),
_Column("net_total", "净合计", "amount", 14),
),
"business-stats": (
_Column("business_no", "业务编号", "text", 20),
_Column("business_date", "业务日期", "date", 12),
_Column("customer_name", "客户", "text", 22),
_Column("owner_personnel_name", "归属人", "text", 12),
_Column("status", "状态", "status", 10),
_Column("total_sale_amount", "销售额", "amount", 14),
_Column("total_ship_amount", "发货额", "amount", 14),
_Column("total_received_amount", "回款额", "amount", 14),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
),
"payment-stats": (
_Column("label", "回款天数区间", "text", 20),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("receipt_count", "回款笔数", "int", 10),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
),
}
def build_workbook(
*,
code: str,
title: str,
filters: str,
rows: list[dict],
summary: dict | None,
buckets: dict | None,
template_version: str,
operator_name: str,
company_name: str,
) -> bytes:
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。"""
workbook = Workbook()
sheet = workbook.active
sheet.title = _sheet_name(code)
columns = _COLUMNS[code]
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
if rows:
for row in rows:
_write_row(sheet, columns, row)
else:
sheet.append(["本筛选条件无数据"])
_merge_row(sheet, len(columns))
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
# 回款统计附维度对比 sheet(客户类型/产品类别),与区间表共用页眉格式
if code == "payment-stats" and buckets:
for dim_key, dim_title in (
("by_customer_type", "客户类型对比"),
("by_product_category", "产品类别对比"),
):
extra = workbook.create_sheet(dim_title)
_write_dimension_sheet(
extra,
dim_title,
company_name,
buckets.get(dim_key) or [],
template_version,
operator_name,
)
buffer = BytesIO()
workbook.save(buffer)
return buffer.getvalue()
def _sheet_name(code: str) -> str:
return {
"personal-detail": "个人绩效明细",
"department-summary": "部门绩效汇总",
"business-stats": "业务统计",
"payment-stats": "回款统计",
}[code]
def _write_header(
sheet,
columns: tuple[_Column, ...],
company_name: str,
title: str,
filters: str,
template_version: str,
operator_name: str,
) -> None:
"""页眉四行 + 表头行;返回后 sheet.max_row 即数据行起点。"""
sheet.append([company_name])
_merge_row(sheet, len(columns))
sheet.cell(row=1, column=1).font = _COMPANY_FONT
sheet.cell(row=1, column=1).alignment = Alignment(horizontal="left", vertical="center")
sheet.append([title])
_merge_row(sheet, len(columns))
sheet.cell(row=2, column=1).font = _TITLE_FONT
sheet.cell(row=2, column=1).alignment = Alignment(horizontal="center", vertical="center")
sheet.append([f"筛选条件:{filters}"])
_merge_row(sheet, len(columns))
sheet.append(
[
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
f"模板版本:{template_version}"
]
)
_merge_row(sheet, len(columns))
sheet.append([])
sheet.append([column.title for column in columns])
header_row = sheet.max_row
for cell in sheet[header_row]:
cell.font = _HEADER_FONT
cell.fill = _HEADER_FILL
cell.border = _BORDER
cell.alignment = Alignment(horizontal="center", vertical="center")
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
def _merge_row(sheet, column_count: int) -> None:
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
row = sheet.max_row
if column_count > 1:
sheet.merge_cells(start_row=row, start_column=1, end_row=row, end_column=column_count)
def _write_row(sheet, columns: tuple[_Column, ...], row: dict) -> None:
"""数据行:按列类型写单元格(金额 Decimal、日期 date、文本防注入)。"""
values = []
for column in columns:
raw = row.get(column.key)
values.append(_cell_value(column, raw))
sheet.append(values)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
elif column.kind == "date":
cell.number_format = _DATE_FORMAT
def _cell_value(column: _Column, raw):
"""单值转换:标签列转中文,文本列防公式注入。"""
if raw is None:
return None
if column.kind == "amount":
return raw if isinstance(raw, Decimal) else Decimal(str(raw))
if column.kind == "date":
return raw
if column.kind == "int":
return int(raw)
if column.kind == "customer":
return CUSTOMER_TYPE_LABELS.get(str(raw), str(raw))
if column.kind == "category":
return PRODUCT_CATEGORY_LABELS.get(str(raw), str(raw))
if column.kind == "status":
return BUSINESS_STATUS_LABELS.get(int(raw), str(raw))
return _safe_text(raw)
def _safe_text(value) -> str:
"""文本防公式注入:以 = + - @ 或制表符开头时前置单引号。"""
text = str(value)
return "'" + text if text.startswith(_FORMULA_PREFIXES) else text
def _write_total(sheet, columns: tuple[_Column, ...], rows: list[dict]) -> None:
"""合计行:金额列按行求和后两位收口(不写 Excel 公式)。
「合计」标签只写在第一个非金额列;其余非金额列留空——否则每遇到
一个文本列都会重复写一次标签,合计行会满屏「合计」。
"""
if not rows:
return
totals = []
label_written = False
for column in columns:
if column.kind != "amount":
totals.append("合计" if not label_written else None)
label_written = True
continue
total = sum(
(
row.get(column.key)
for row in rows
if isinstance(row.get(column.key), (Decimal, int))
),
Decimal("0"),
)
totals.append(total.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP))
sheet.append(totals)
for index, column in enumerate(columns, start=1):
cell = sheet.cell(row=sheet.max_row, column=index)
cell.font = _HEADER_FONT
cell.border = _BORDER
if column.kind == "amount":
cell.number_format = _AMOUNT_FORMAT
def _write_signature(sheet, column_count: int) -> None:
"""财务复核栏 / 领导签字栏 / 日期栏(FR-032 模板要素)。"""
sheet.append([])
sheet.append(["财务复核:________________", "", "日期:______年__月__日"])
sheet.append(["领导签字:________________", "", "日期:______年__月__日"])
for row_index in (sheet.max_row - 1, sheet.max_row):
sheet.cell(row=row_index, column=1).alignment = Alignment(horizontal="left")
def _autofit(sheet, columns: tuple[_Column, ...]) -> None:
"""列宽按定义表设置(CJK 按 2 字符宽,不用默认 8.43)。"""
for index, column in enumerate(columns, start=1):
sheet.column_dimensions[get_column_letter(index)].width = column.width
def _write_dimension_sheet(
sheet,
title: str,
company_name: str,
rows: list[dict],
template_version: str,
operator_name: str,
) -> None:
"""维度对比 sheet(客户类型/产品类别)。"""
columns = (
_Column("label", "维度", "text", 16),
_Column("receipt_amount", "回款金额", "amount", 16),
_Column("performance_amount", "绩效金额", "amount", 14),
_Column("payable_amount", "应付绩效", "amount", 14),
_Column("ledger_count", "台账行数", "int", 10),
)
_write_header(sheet, columns, company_name, title, "全部", template_version, operator_name)
for row in rows:
_write_row(sheet, columns, row)
_write_total(sheet, columns, rows)
_write_signature(sheet, len(columns))
_autofit(sheet, columns)
+698
View File
@@ -0,0 +1,698 @@
"""报表统计服务(M4 ⑤):四张报表取数编排 + Excel 导出编排。
全局口径(四表共用,改动须同步 backend/README「报表口径」小节):
1. 数据源 ``performance_ledgers`` 固定 **is_active=1 AND calc_type=1**(单笔
生效行);经理聚合行(calc_type=2)只在部门汇总作冻结对照单读——把
聚合额混进明细会让「汇总 = 明细合计」失效;
2. 金额全程 Decimal(两位收口 ROUND_HALF_UP);比例原样输出 ``rate_text``
(如 88/9000),禁止转 float 或截断展示;
3. 调整单并入:仅 status=2(生效);direction=1 补发记 +、2 扣减记 −
(金额恒正,方向表达语义);
4. 离职人员不按 ``personnel.status`` 过滤——历史业绩留在原团队(FR-030
「变更不回溯」原则);无成员团队仍出 0 值行;
5. 团队基数直接取 ``manager_share.collect_team_buckets`` 的分桶结果,与
月结聚合行同源(不是报表侧另算一遍)。
"""
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import select
from app.core import report_meta
from app.core.constants import ACTION_EXPORT, MODULE_REPORT
from app.core.exceptions import BadRequestError
from app.core.report_meta import (
REPORT_EXPORT_MAX_ROWS,
REPORT_TEMPLATE_VERSION,
DayBucket,
)
from app.models import BusinessRole, PerformanceLedger, Personnel, User
from app.models.settlement import ADJUSTMENT_DIRECTION_EXTRA
from app.repositories import report_repository
from app.services import audit_service, manager_share
from app.services.audit_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share
# ==== 元数据 ====
def get_payment_day_buckets() -> list[dict]:
"""回款天数区间元数据(前端下拉唯一数据源,禁止前端硬编码档位)。"""
return [
{"code": b.code, "label": b.label, "min_days": b.min_days, "max_days": b.max_days}
for b in report_meta.get_payment_day_buckets()
]
# ==== 个人绩效明细(FR-029)====
def list_personal_detail(session, query) -> dict:
"""明细分页 + 全筛选合计(summary 与分页同条件,避免页脚合计≠全部结果)。"""
rows = report_repository.list_personal_detail(
session, query, offset=query.offset, limit=query.page_size
)
role_names = _role_names(session)
sums = report_repository.sum_personal_detail(session, query)
return {
"list": [_person_row(r, role_names) for r in rows],
"total": report_repository.count_personal_detail(session, query),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {
"row_count": sums["row_count"],
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
"converted_profit_total": _q2(_dec(sums["converted_profit_total"])),
},
}
def _person_row(row: PerformanceLedger, role_names: dict[str, str]) -> dict:
"""台账行 → 明细行出参(金额保持 Decimal,序列化交由信封层)。"""
return {
"ledger_id": row.id,
"business_id": row.business_id,
"business_no": row.business_no,
"personnel_id": row.personnel_id,
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"role_name": role_names.get(row.role_code, row.role_code),
"customer_type": row.customer_type,
"product_category": row.product_category,
"ship_date": row.ship_date,
"receipt_date": row.receipt_date,
"payment_days": row.payment_days,
"rate_text": row.rate_text,
"weight_r": row.weight_r,
"payment_weight": row.payment_weight,
"performance_amount": row.performance_amount,
"payable_amount": row.payable_amount,
"converted_profit": row.converted_profit,
"is_probation": row.is_probation,
"missing_cost": row.missing_cost,
"calc_version": row.calc_version,
"settlement_id": row.settlement_id,
}
def _role_names(session) -> dict[str, str]:
"""岗位编码 → 名称一次查询(明细与部门行共用,避免 N+1)。"""
return {
r.role_code: r.role_name
for r in session.execute(select(BusinessRole)).scalars()
}
def _dec(value) -> Decimal | None:
"""SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。"""
if value is None:
return None
return value if isinstance(value, Decimal) else Decimal(str(value))
def _q2(value: Decimal | None) -> Decimal | None:
"""两位小数收口(HALF_UP,与核算/月结舍入口径一致)。"""
if value is None:
return None
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
# ==== 部门绩效汇总(FR-030)====
def department_summary(session, query) -> dict:
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。"""
month = query.settlement_month
rows = report_repository.list_month_receipt_rows(session, month)
share = manager_share.manager_share_params(session, month)
buckets = manager_share.collect_team_buckets(session, rows, share.include_manager_personal)
company_base = manager_share.company_base_of(rows, share.include_manager_personal)
aggregates = {
row.personnel_id: row
for row in report_repository.list_month_manager_aggregates(session, month)
}
adjustments = _adjustments_by_person(report_repository.list_month_adjustments(session, month))
member_map = _member_map(rows)
manager_ids = _manager_ids(session, buckets, query.manager_id)
# 岗位判定范围必须覆盖「无成员的经理」——只按 member_map 取会让零行团队被跳过
manager_of, manager_role = _team_relations(session, member_map, manager_ids)
teams: list[dict] = []
for manager_id in manager_ids:
manager = session.get(Personnel, manager_id)
if manager is None or manager_role.get(manager_id) != "sales_manager":
continue # 非营销经理的上级不展示团队行(与月结聚合同口径)
aggregate = aggregates.get(manager_id)
member_rows = _member_rows(
manager_id, member_map, manager_of, adjustments, share.include_manager_personal
)
team_extra = sum((m["adjust_extra"] for m in member_rows), Decimal("0"))
team_deduct = sum((m["adjust_deduct"] for m in member_rows), Decimal("0"))
team_base = _dec(buckets.get(manager_id, {}).get("performance", Decimal("0")))
# 12.5% 与 5% 分列(FR-030「全公司 5% 部分单列」);两项之和才是月结
# 聚合行的金额(frozen_amount 对照),重复计入会双倍展示
team_share = _team_share(team_base, share.team_pct)
company_share = _company_share(company_base, share.company_pct)
teams.append(
{
"manager_id": manager_id,
"manager_name": manager.personnel_name,
"manager_status": manager.status,
"member_count": len(member_rows),
"team_base": _q2(team_base),
"team_share_amount": _q2(team_share),
"company_share_amount": _q2(company_share),
"company_base_ratio": _ratio(team_base, company_base),
"adjust_extra": _q2(team_extra),
"adjust_deduct": _q2(team_deduct),
"adjust_net": _q2(team_extra - team_deduct),
"net_total": _q2(team_share + company_share + team_extra - team_deduct),
"frozen_amount": aggregate.performance_amount if aggregate else None,
"aggregate_ledger_id": aggregate.id if aggregate else None,
"members": member_rows,
}
)
unassigned = _unassigned_rows(member_map, manager_of, manager_role, adjustments)
return {
"settlement_month": month,
"params": {
"team_text": share.team_text,
"company_text": share.company_text,
"manager_personal_included": 1 if share.include_manager_personal else 0,
"params_as_of": str(share.param_date),
},
"teams": teams,
"unassigned": unassigned,
"company": {
"team_base_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
"company_base": _q2(company_base),
"company_share_pct": share.company_text,
"company_share_amount": _q2(_company_share(company_base, share.company_pct)),
},
"totals": {
"performance_total": _q2(sum((t["team_base"] for t in teams), Decimal("0"))),
"payable_total": _q2(sum((t["net_total"] for t in teams), Decimal("0"))),
},
}
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
return manager_team_share(
team_base, Decimal("0"), team_pct=team_pct, company_pct=Fraction(0)
)
def _company_share(company_base: Decimal, company_pct: Fraction) -> Decimal:
"""5% 全公司分成:团队部分传 0,仅取 company_pct 项。"""
return manager_team_share(
Decimal("0"), company_base, team_pct=Fraction(0), company_pct=company_pct
)
def _ratio(part: Decimal, whole: Decimal) -> Decimal:
"""占比(除零返回 0,不抛 ZeroDivisionError)。"""
if not whole:
return Decimal("0")
return (part / whole).quantize(Decimal("0.0001"), rounding=ROUND_HALF_UP)
def _adjustments_by_person(orders) -> dict[int, dict]:
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。"""
by_person: dict[int, dict] = {}
for order in orders:
acc = by_person.setdefault(
order.personnel_id, {"extra": Decimal("0"), "deduct": Decimal("0")}
)
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
acc["extra"] += order.amount
else:
acc["deduct"] += order.amount
return by_person
def _member_map(rows) -> dict[int, dict]:
"""台账行按人求和(成员明细的唯一数据源),经理个人户金额单独记账。"""
members: dict[int, dict] = {}
for row in rows:
acc = members.setdefault(
row.personnel_id,
{
"personnel_name": row.personnel_name,
"role_code": row.role_code,
"is_probation": row.is_probation,
"performance": Decimal("0"),
"payable": Decimal("0"),
"manager_personal_performance": Decimal("0"),
"manager_personal_payable": Decimal("0"),
"ledger_ids": [],
"has_manager_personal": False,
},
)
performance = row.performance_amount or Decimal("0")
payable = row.payable_amount or Decimal("0")
acc["performance"] += performance
acc["payable"] += payable
acc["ledger_ids"].append(row.id)
if row.customer_type == "manager_personal":
acc["has_manager_personal"] = True
acc["manager_personal_performance"] += performance
acc["manager_personal_payable"] += payable
return members
def _team_relations(
session, member_map: dict[int, dict], manager_ids: list[int]
) -> tuple[dict[int, int | None], dict[int, str]]:
"""人员 → 直属经理与人员 → 岗位编码(各一次查询,避免逐人 get 的 N+1)。
查询范围 = 成员 ∪ 经理枚举:无成员的经理也要有岗位判定,否则「零行团队」
会被岗位过滤静默跳过(FR-030 要求无团队成员出 0 行)。
经理判定与 ``manager_share.collect_team_buckets`` 的实时上溯同口径:
离职人员的 manager_id 保留,历史业绩因此留在原团队。
"""
pids = list(set(member_map) | set(manager_ids))
if not pids:
return {}, {}
people = list(session.execute(select(Personnel).where(Personnel.id.in_(pids))).scalars())
manager_of = {p.id: p.manager_id for p in people}
role_by_id = {
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
}
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
return manager_of, manager_role
def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
"""经理枚举:数据中出现的经理 ∪ 在职营销经理(无成员也要出 0 值行)。"""
if only is not None:
return [only]
found = set(buckets)
role = session.execute(
select(BusinessRole).where(BusinessRole.role_code == "sales_manager")
).scalar_one_or_none()
if role is not None:
active = session.execute(
select(Personnel.id).where(Personnel.business_role_id == role.id, Personnel.status == 1)
).scalars()
found.update(active)
return sorted(found)
def _member_rows(
manager_id: int,
member_map: dict[int, dict],
manager_of: dict[int, int | None],
adjustments: dict[int, dict],
include_manager_personal: bool,
) -> list[dict]:
"""团队内成员行:业务员按 manager_id 归属;经理个人户按开关归属本人。"""
pids = [pid for pid, mid in manager_of.items() if mid == manager_id and pid != manager_id]
# 经理本人(经理个人户行):#12 开关开启时计入本团队基数
if include_manager_personal and manager_id in member_map:
pids.append(manager_id)
return [_member_row(pid, member_map[pid], adjustments) for pid in pids]
def _member_row(pid: int, member: dict, adjustments: dict[int, dict]) -> dict:
"""单条成员行(绩效/应付求和 + 调整净额 + 台账 id 列表)。"""
adj = adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
net = member["payable"] + adj["extra"] - adj["deduct"]
return {
"personnel_id": pid,
"personnel_name": member["personnel_name"],
"role_code": member["role_code"],
"is_probation": member["is_probation"],
"performance_sum": _q2(member["performance"]),
"payable_sum": _q2(member["payable"]),
"adjust_extra": _q2(adj["extra"]),
"adjust_deduct": _q2(adj["deduct"]),
"adjust_net": _q2(adj["extra"] - adj["deduct"]),
"net_total": _q2(net),
"ledger_ids": member["ledger_ids"],
}
def _unassigned_rows(
member_map: dict[int, dict],
manager_of: dict[int, int | None],
manager_role: dict[int, str],
adjustments: dict[int, dict],
) -> list[dict]:
"""未进入团队基数的成员:无经理业务员 + 非营销经理上级的下属。
经理个人户业绩默认不计入任何基数(#12 开关关闭时),也不在此展示——
其金额仍可在个人明细报表中查询,避免与「团队基数 + 未分配」口径混淆。
"""
rows = []
for pid, member in member_map.items():
mid = manager_of.get(pid)
if mid is not None and manager_role.get(mid) == "sales_manager":
continue
regular = member["performance"] - member["manager_personal_performance"]
if regular == 0 and member["manager_personal_performance"] != 0:
continue
row = _member_row(pid, member, adjustments)
row["performance_sum"] = _q2(regular)
row["payable_sum"] = _q2(member["payable"] - member["manager_personal_payable"])
rows.append(row)
return rows
# ==== 业务统计(FR-031 之一)====
def business_stats(session, query) -> dict:
"""业务统计分页 + 全筛选合计;回款/绩效为业务累计值。"""
rows = report_repository.list_business_stats(
session, query, offset=query.offset, limit=query.page_size
)
sums = report_repository.sum_business_stats(session, query)
return {
"list": [_business_row(r) for r in rows],
"total": report_repository.count_business_stats(session, query),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {
"row_count": sums["row_count"],
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
"sale_total": _q2(_dec(sums["sale_total"])),
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
},
}
def _business_row(row) -> dict:
"""业务统计行(ORM 行与三个联查列)。"""
business = row[0]
return {
"business_id": business.id,
"business_no": business.business_no,
"business_date": business.business_date,
"status": business.status,
"customer_name": row.customer_name,
"owner_personnel_name": row.owner_personnel_name,
"total_sale_amount": business.total_sale_amount,
"total_ship_amount": business.total_ship_amount,
"total_received_amount": business.total_received_amount,
"performance_amount": row.performance_amount,
"payable_amount": row.payable_amount,
}
# ==== 回款统计(FR-031 之二)====
def payment_stats(session, query) -> dict:
"""区间/客户类型/产品类别三维度统计;空桶恒在且为 0。"""
perf_rows = report_repository.group_payment_by_days(session, query)
receipt_rows = report_repository.group_payment_receipt_by_days(session, query)
buckets = _bucket_rows(perf_rows, receipt_rows)
by_customer = _group_rows(report_repository.group_payment_by_customer_type(session, query))
by_category = _group_rows(report_repository.group_payment_by_product_category(session, query))
return {
"settlement_month": query.settlement_month,
"buckets": buckets,
"by_customer_type": by_customer,
"by_product_category": by_category,
"totals": {
"receipt_amount": _q2(
sum((b["receipt_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
"receipt_count": sum(b["receipt_count"] for b in buckets),
"performance_amount": _q2(
sum((b["performance_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
"payable_amount": _q2(
sum((b["payable_amount"] or Decimal("0") for b in buckets), Decimal("0"))
),
},
}
def _bucket_rows(perf_rows, receipt_rows) -> list[dict]:
"""把(天数, 类型, 类别)精确值聚合结果按 8 档区间分桶(Python 侧分桶单点)。"""
perf_acc: dict[str, dict] = {}
for row in perf_rows:
bucket = report_meta.bucket_of(row.payment_days)
acc = perf_acc.setdefault(
bucket.code, {"performance": Decimal("0"), "payable": Decimal("0"), "count": 0}
)
acc["performance"] += _dec(row.performance_total)
acc["payable"] += _dec(row.payable_total)
acc["count"] += int(row.ledger_count)
receipt_acc: dict[str, dict] = {}
for row in receipt_rows:
bucket = report_meta.bucket_of(row.payment_days)
acc = receipt_acc.setdefault(bucket.code, {"amount": Decimal("0"), "count": 0})
acc["amount"] += _dec(row.receipt_amount)
acc["count"] += int(row.receipt_count)
zero = Decimal("0")
return [
{
"code": bucket.code,
"label": bucket.label,
"min_days": bucket.min_days,
"max_days": bucket.max_days,
# 空桶显示 0 而非 null(FR-031「空区间正常显示」)
"receipt_amount": _q2(receipt_acc.get(bucket.code, {}).get("amount", zero)),
"receipt_count": receipt_acc.get(bucket.code, {}).get("count", 0),
"performance_amount": _q2(perf_acc.get(bucket.code, {}).get("performance", zero)),
"payable_amount": _q2(perf_acc.get(bucket.code, {}).get("payable", zero)),
"ledger_count": perf_acc.get(bucket.code, {}).get("count", 0),
}
for bucket in report_meta.get_payment_day_buckets()
]
def _group_rows(rows) -> list[dict]:
"""维度对比行(客户类型/产品类别),标签来自 report_meta。
仓储返回元组 (dim, 回款额, 绩效, 应付, 台账行数),按位置解包。
"""
labels = report_meta.CUSTOMER_TYPE_LABELS | report_meta.PRODUCT_CATEGORY_LABELS
result = []
for dim, receipt_amount, performance_amount, payable_amount, ledger_count in rows:
result.append(
{
"code": dim,
"label": labels.get(dim, dim or "未分类"),
"receipt_amount": _q2(_dec(receipt_amount)),
"performance_amount": _q2(_dec(performance_amount)),
"payable_amount": _q2(_dec(payable_amount)),
"ledger_count": int(ledger_count),
}
)
return result
def payment_detail(session, query) -> dict:
"""区间下钻分页(bucket 未注册 → 400)。"""
bucket = _bucket_by_code(query.bucket)
rows = report_repository.list_payment_detail(
session, query, bucket, offset=query.offset, limit=query.page_size
)
return {
"list": [_payment_row(r) for r in rows],
"total": report_repository.count_payment_detail(session, query, bucket),
"pageNum": query.page_num,
"pageSize": query.page_size,
"summary": {"row_count": len(rows)},
}
def _payment_row(row) -> dict:
"""下钻行(台账行 + 回款金额/批次号)。"""
ledger = row[0]
return {
"ledger_id": ledger.id,
"receipt_id": ledger.receipt_id,
"receipt_batch_no": row.receipt_batch_no,
"amount": row.receipt_amount,
"business_id": ledger.business_id,
"business_no": ledger.business_no,
"personnel_id": ledger.personnel_id,
"personnel_name": ledger.personnel_name,
"customer_type": ledger.customer_type,
"product_category": ledger.product_category,
"ship_date": ledger.ship_date,
"receipt_date": ledger.receipt_date,
"payment_days": ledger.payment_days,
"performance_amount": ledger.performance_amount,
"payable_amount": ledger.payable_amount,
}
def _bucket_by_code(code: str) -> DayBucket:
"""按编码取区间桶;未注册编码返回 400(防前端传参与后端档位漂移)。"""
for bucket in report_meta.get_payment_day_buckets():
if bucket.code == code:
return bucket
raise BadRequestError(f"未知的回款天数区间:{code}")
# ==== 导出编排(FR-032)====
EXPORT_FILENAMES = {
"personal-detail": "personal_detail",
"department-summary": "department_summary",
"business-stats": "business_stats",
"payment-stats": "payment_stats",
}
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
"""导出 Excel:上限校验 → 取数 → 构建 → 审计留痕(独立 commit)。
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
"""
from app.services import report_export
rows, context = _export_payload(session, code, query)
with audit_service.audit(
session,
operator=operator,
record=AuditRecord(
module=MODULE_REPORT,
action=ACTION_EXPORT,
summary=f"导出{context['title']}({context['row_count']} 行)",
request_data=query.model_dump(exclude_none=True),
),
meta=meta,
):
content = report_export.build_workbook(
code=code,
title=context["title"],
filters=context["filters"],
rows=rows,
summary=context.get("summary"),
buckets=context.get("buckets"),
template_version=REPORT_TEMPLATE_VERSION,
operator_name=getattr(operator, "username", ""),
company_name=context["company_name"],
)
return content, EXPORT_FILENAMES[code]
def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
"""按报表编码取全量数据并做上限校验(复用查询函数,不走分页)。"""
if code == "personal-detail":
total = report_repository.count_personal_detail(session, query)
_check_export_limit(total)
rows = report_repository.list_personal_detail(
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
)
role_names = _role_names(session)
sums = report_repository.sum_personal_detail(session, query)
return [_person_row(r, role_names) for r in rows], {
"title": "个人绩效工资明细表",
"row_count": total,
"filters": _filter_text(query),
"summary": {
"performance_total": _q2(_dec(sums["performance_total"])),
"payable_total": _q2(_dec(sums["payable_total"])),
},
"company_name": _company_name(session),
}
if code == "department-summary":
data = department_summary(session, query)
member_total = sum(len(t["members"]) for t in data["teams"])
_check_export_limit(member_total)
rows = _department_export_rows(data)
return rows, {
"title": "部门绩效工资汇总表",
"row_count": member_total,
"filters": f"统计月份 {query.settlement_month}",
"summary": data["company"],
"company_name": _company_name(session),
}
if code == "business-stats":
total = report_repository.count_business_stats(session, query)
_check_export_limit(total)
rows = report_repository.list_business_stats(
session, query, offset=0, limit=REPORT_EXPORT_MAX_ROWS
)
sums = report_repository.sum_business_stats(session, query)
return [_business_row(r) for r in rows], {
"title": "业务统计表",
"row_count": total,
"filters": _filter_text(query),
"summary": {
"ship_total": _q2(_dec(sums["ship_total"])),
"received_total": _q2(_dec(sums["received_total"])),
"performance_total": _q2(_dec(sums["performance_total"])),
},
"company_name": _company_name(session),
}
if code == "payment-stats":
data = payment_stats(session, query)
rows = data["buckets"]
return rows, {
"title": "回款情况统计表",
"row_count": len(rows),
"filters": _filter_text(query),
"summary": data["totals"],
"buckets": data,
"company_name": _company_name(session),
}
raise BadRequestError(f"未知的报表编码:{code}")
def _check_export_limit(row_count: int) -> None:
"""导出上限(需求「超限提示」口径):超过即 400 引导缩小筛选。"""
if row_count > REPORT_EXPORT_MAX_ROWS:
raise BadRequestError(
f"导出数据量 {row_count} 行超过上限 {REPORT_EXPORT_MAX_ROWS} 行,"
"请缩小筛选范围(如按月导出)"
)
def _department_export_rows(data: dict) -> list[dict]:
"""部门汇总导出展平:每个成员一行,团队列冗余(便于签字稿核对)。"""
rows = []
for team in data["teams"]:
for member in team["members"]:
rows.append(
{
"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"],
}
)
return rows
def _filter_text(query) -> str:
"""筛选条件摘要(导出页眉展示;未设置项不展示)。"""
parts = []
for field, label in (
("settlement_month", "统计月份"),
("customer_type", "客户类型"),
("product_category", "产品类别"),
("role_code", "岗位"),
("keyword", "关键字"),
("date_from", "日期起"),
("date_to", "日期止"),
):
value = getattr(query, field, None)
if value:
parts.append(f"{label} {value}")
return " | ".join(parts) or "全部"
def _company_name(session) -> str:
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
from app.services import system_setting_service
return system_setting_service.get_setting(session).company_name
+19 -87
View File
@@ -9,11 +9,8 @@
"""
import logging
from calendar import monthrange
from dataclasses import dataclass
from datetime import date
from decimal import ROUND_HALF_UP, Decimal
from fractions import Fraction
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -45,7 +42,7 @@ from app.schemas.settlement import (
SettlementOut,
SettlementQuery,
)
from app.services import approval_service, audit_service
from app.services import approval_service, audit_service, manager_share
from app.services.approval_service import AuditRecord, RequestMeta
from app.services.formula import manager_team_share, monthly_salary
@@ -352,82 +349,28 @@ def _confirm_scope(session: Session, month: str) -> list:
)
@dataclass(frozen=True, slots=True)
class _ShareParams:
"""经理分成取参与参数原文(同一次生成共用一份,随月月末时点化)。"""
def _manager_share_params(session: Session, month: str) -> manager_share.ManagerShareParams:
"""薄委托 manager_share 共享模块:月结聚合与报表部门汇总同源取参。
month: str
param_date: date
team_text: str
company_text: str
team_pct: Fraction
company_pct: Fraction
include_manager_personal: bool
def _manager_share_params(session: Session, month: str) -> _ShareParams:
"""解析经理分成参数(锚定所属月月末,取参与台账行口径同源)。
参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)。
#12 开关(manager.personal.included)与分成比例同日取参,答复后调整可追溯。
迁移说明(2026-10-19):实现原样迁至 manager_share,行为零变化;
参数按百分数存储(12.5 表示 12.5%),调用方不得再乘 100。
"""
from app.services.param_service import parse_fraction, resolve_snapshot
param_date = _month_end(month)
snapshot = resolve_snapshot(session, param_date)
team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company")
return _ShareParams(
month=month,
param_date=param_date,
team_text=team_text,
company_text=company_text,
team_pct=parse_fraction(team_text),
company_pct=parse_fraction(company_text),
include_manager_personal=snapshot.get("manager.personal.included") == "1",
)
def _add_team_performance(managers: dict[int, dict], manager_id: int, row) -> None:
"""把一行业绩并入经理的团队桶(首行固化业务锚点,成员名去重收集)。"""
bucket = managers.setdefault(
manager_id,
{
"performance": Decimal("0"),
"business_id": row.business_id,
"business_no": row.business_no,
"names": set(),
},
)
bucket["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name)
return manager_share.manager_share_params(session, month)
def _collect_team_buckets(
session: Session, ledger_rows: list, include_manager_personal: bool
) -> dict[int, dict]:
"""按经理采集团队业绩。
业务员行经 manager_id 上溯到其经理;经理个人户行(#12 开关计入时)
归属经理本人——行 personnel_id 即经理,不走上溯(经理没有上级,
按上溯会被跳过漏计)。
"""
managers: dict[int, dict] = {}
for row in ledger_rows:
if row.customer_type == "manager_personal":
if include_manager_personal:
_add_team_performance(managers, row.personnel_id, row)
continue # 默认不计入聚合基数(#12 参数化)
person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None
if manager_id is not None:
_add_team_performance(managers, manager_id, row)
return managers
"""薄委托 manager_share 共享模块:按经理采集团队业绩(行为零变化)。"""
return manager_share.collect_team_buckets(session, ledger_rows, include_manager_personal)
def _aggregate_row(
session: Session, manager, bucket: dict, company_base: Decimal, share: _ShareParams
session: Session,
manager,
bucket: dict,
company_base: Decimal,
share: manager_share.ManagerShareParams,
):
"""单经理聚合台账行(必须落库:月结 ledger_ids 与报表下钻的锚点)。"""
from app.models import PerformanceLedger
@@ -487,16 +430,8 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
share = _manager_share_params(session, month)
managers = _collect_team_buckets(session, ledger_rows, share.include_manager_personal)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);
# 口径逐行固定,循环外算一次
company_base = sum(
(
r.performance_amount or Decimal("0")
for r in ledger_rows
if share.include_manager_personal or r.customer_type != "manager_personal"
),
Decimal("0"),
)
# 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤);共享口径单点
company_base = manager_share.company_base_of(ledger_rows, share.include_manager_personal)
aggregates: list = []
for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id)
@@ -514,15 +449,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
def _month_end(month: str) -> date:
"""YYYY-MM → 所属月月末(月结取参锚点)。
"""薄委托 manager_share 共享模块:YYYY-MM → 所属月月末(取参锚点)。
月结是对过去某月的金额计算,取参必须锚定业务月份——按 date.today()
会让补生成历史月份的结果随操作日期漂移(违背「参数按生效日期时点化」
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
更细的业务日期,取该月最后可确定的生效时点。
保留包装:tests/unit/test_settlement_helpers.py 直接 import 本名字;
「补生成历史月份结果不随操作日漂移」的原因说明见 manager_share.month_end。
"""
year, mon = int(month[:4]), int(month[5:7])
return date(year, mon, monthrange(year, mon)[1])
return manager_share.month_end(month)
def _salary_mode_of(session: Session, role_code: str, month: str) -> str | None:
+13
View File
@@ -136,6 +136,17 @@ uv run pytest tests/integration -q # 仅集成
运行:`uv run pytest tests/integration/test_{settings,secondary_allocation,approval_flow}.py tests/unit/test_secondary_allocation_schema.py -q` → **44 passed**(2026-10-16);全量回归 `uv run pytest -q` → **697 passed**(基线 625 + 新增 72,5:10)。
### 报表统计(M4 ⑤,2026-10-19 后端完成)
| 文件 | 守什么 | 用例数 |
| --- | --- | --- |
| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、12.5% 分成按参数、无成员经理出 0 值行;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**(防翻倍)、绩效合计=台账;业务统计排除经理聚合行(生成月结后绩效不被整团队聚合额污染);导出 xlsx 可解析且含「财务复核/领导签字」栏 + 操作日志留痕(module=报表统计/action=导出);普通用户 403 且文案「无权访问报表统计」 | 7 |
| `tests/unit/test_report_helpers.py` | 区间分桶边界(NULL/负值/0/1/30/…/361+)与 8 档编码唯一;客户类型、产品类别标签覆盖全部常量枚举(防新增枚举后报表少一类);Excel 模板纯构建含页眉/签字栏、空数据提示行、金额为数值单元格且两位格式、合计行=明细之和、文本公式注入前置单引号、回款统计两个维度对比 sheet、业务统计状态转中文 | 11 |
运行:`uv run pytest tests/integration/test_reports.py -q` → **7 passed**(2026-10-19);`uv run pytest tests/unit/test_report_helpers.py -q` → **11 passed**(2026-10-19);全量回归见运行记录。
**待补**:e2e `frontend/e2e/report_flow.py`(四页筛选/导出下载/打印视图,需 `E2E_ADMIN_PASSWORD`)。
## 三、运行记录
| 日期 | 范围 | 用例数 | 结果 | 备注 |
@@ -156,6 +167,8 @@ uv run pytest tests/integration -q # 仅集成
| 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) |
| 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 |
| 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 |
| 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 |
| 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 |
## 四、测试发现并修复的缺陷
+308
View File
@@ -0,0 +1,308 @@
"""报表统计集成测试(M4 ⑤)。
守的口径(破坏了就是报表数字与台账对不上):
- 个人明细与台账逐笔一致;summary 为**全筛选**合计而非当前页合计
- 部门汇总团队基数 = 成员绩效合计;12.5%/5% 分成按参数;无成员经理出 0 行
- 回款统计 8 个区间桶恒在(空桶为 0);**二次分摊拆行时回款金额只计一次**
- 业务统计排除经理聚合行(calc_type=2),绩效不被整团队聚合额污染
- 导出 xlsx 可解析且含签字栏;导出行为入操作日志
- 权限:普通用户 403 且文案为「无权访问报表统计」
"""
import uuid
from decimal import Decimal
import pytest
from fastapi.testclient import TestClient
pytestmark = pytest.mark.integration
_REPORTS = "/api/v1/reports"
_PERSONNEL = "/api/v1/base/personnel"
_BUSINESSES = "/api/v1/business/businesses"
_SHIPMENTS = "/api/v1/business/shipments"
_RECEIPTS = "/api/v1/business/receipts"
_CALC = "/api/v1/calc"
_SETTLEMENTS = "/api/v1/settlements"
def _role_id(code: str) -> int:
"""取内置业务岗位 id(salesperson / sales_manager)。"""
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import BusinessRole
factory = get_session_factory()
with factory() as session:
return int(
session.execute(
select(BusinessRole.id).where(
BusinessRole.role_code == code, BusinessRole.status == 1
)
).scalar_one()
)
def _person(client: TestClient, headers: dict, *, role: str = "salesperson", manager_id=None) -> dict:
body = {
"personnel_name": f"t_报表_{uuid.uuid4().hex[:8]}",
"business_role_id": _role_id(role),
"salary_standard": "3000.00",
}
if manager_id is not None:
body["manager_id"] = manager_id
resp = client.post(_PERSONNEL, json=body, headers=headers)
assert resp.status_code == 200, resp.text
return resp.json()["data"]
def _full_chain(
client: TestClient,
headers: dict,
*,
owner_id: int,
customer_id: int | None = None,
allocations: list[dict] | None = None,
) -> tuple[dict, dict]:
"""业务(单行 100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。"""
from tests.integration.factories import business_item, business_payload, customer
cust = customer(client, headers) if customer_id is None else {"id": customer_id}
payload = business_payload(
cust["id"],
owner_personnel_id=owner_id,
items=[business_item(cost_amount="3000.00")],
)
if allocations:
payload["secondary_allocations"] = allocations
resp = client.post(_BUSINESSES, json=payload, headers=headers)
assert resp.status_code == 200, resp.text
business = resp.json()["data"]
from datetime import date, timedelta
ship_date = (date.today() - timedelta(days=60)).isoformat()
resp = client.post(
_SHIPMENTS,
json={
"business_id": business["id"],
"ship_date": ship_date,
"items": [{"product_name": "t_发货物", "quantity": "1", "sale_price": "1.00"}],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
shipment = resp.json()["data"]
resp = client.post(
_RECEIPTS,
json={
"business_id": business["id"],
"receipt_date": date.today().isoformat(),
"amount": "10000.00",
"shipment_id": shipment["id"],
},
headers=headers,
)
assert resp.status_code == 200, resp.text
receipt = resp.json()["data"]
resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers)
assert resp.status_code == 200, resp.text
return business, receipt
def _month() -> str:
from datetime import date
return date.today().strftime("%Y-%m")
def _ledger_sum(headers: dict, month: str) -> Decimal:
"""台账(单笔生效行)同月绩效合计——报表 summary 的对照基准。"""
from sqlalchemy import func, select
from app.core.db import get_session_factory
from app.models import PerformanceLedger
factory = get_session_factory()
with factory() as session:
return Decimal(
str(
session.execute(
select(func.coalesce(func.sum(PerformanceLedger.performance_amount), 0)).where(
PerformanceLedger.settlement_month == month,
PerformanceLedger.calc_type == 1,
PerformanceLedger.is_active == 1,
)
).scalar_one()
)
)
def test_个人明细_与台账一致且summary为全量合计(client: TestClient, admin: dict):
"""明细行金额必须等于台账;pageSize=1 时 summary 仍是全部筛选结果之和。"""
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
month = _month()
resp = client.get(
f"{_REPORTS}/personal-detail",
params={"settlement_month": month, "pageSize": 1},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert data["total"] == 1
expected = _ledger_sum(admin["headers"], month)
assert Decimal(str(data["list"][0]["performance_amount"])) == expected
assert Decimal(str(data["summary"]["performance_total"])) == expected
def test_部门汇总_团队基数等于成员绩效且分成按参数(admin, client: TestClient):
"""团队基数=成员绩效合计;12.5% 团队分成与 5% 全公司分成分别可核。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
month = _month()
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
team = next(t for t in data["teams"] if t["manager_id"] == manager["id"])
ledger_total = _ledger_sum(admin["headers"], month)
assert Decimal(str(team["team_base"])) == ledger_total
expected_share = (ledger_total * Decimal("0.125")).quantize(Decimal("0.01"))
assert Decimal(str(team["team_share_amount"])) == expected_share
assert team["member_count"] == 1
assert data["company"]["company_base"] == team["team_base"]
def test_部门汇总_无成员经理出零值行(admin, client: TestClient):
"""无团队成员 → 0 行展示(FR-030 异常处理)。"""
manager = _person(client, admin["headers"], role="sales_manager")
month = _month()
resp = client.get(
f"{_REPORTS}/department-summary",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
team = next(
(t for t in resp.json()["data"]["teams"] if t["manager_id"] == manager["id"]), None
)
assert team is not None
assert Decimal(str(team["team_base"])) == Decimal("0")
assert team["member_count"] == 0
def test_回款统计_空桶恒在且分摊拆行金额只计一次(admin, client: TestClient):
"""8 个区间桶恒在;二次分配拆行使台账多行但回款金额不得翻倍。"""
owner = _person(client, admin["headers"])
participant = _person(client, admin["headers"])
_full_chain(
client,
admin["headers"],
owner_id=owner["id"],
allocations=[{"personnel_id": participant["id"], "ratio": "30.00"}],
)
month = _month()
resp = client.get(
f"{_REPORTS}/payment-stats",
params={"settlement_month": month},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
data = resp.json()["data"]
assert len(data["buckets"]) == 8
assert [b["code"] for b in data["buckets"]] == [
"le0", "1-30", "31-60", "61-90", "91-180", "181-360", "361+", "none",
]
# 60 天前发货 → 落在 61-90 桶(发货日首日计第 1 天)
bucket = next(b for b in data["buckets"] if b["code"] == "61-90")
assert Decimal(str(bucket["receipt_amount"])) == Decimal("10000.00")
assert bucket["receipt_count"] == 1
# 拆行后台账两行,但绩效合计仍等于台账总和
assert bucket["ledger_count"] == 2
empty = next(b for b in data["buckets"] if b["code"] == "361+")
assert Decimal(str(empty["performance_amount"])) == Decimal("0")
def test_业务统计_绩效排除经理聚合行(admin, client: TestClient):
"""生成月结后出现 calc_type=2 聚合行,业务统计不得把聚合额算进单业务。"""
manager = _person(client, admin["headers"], role="sales_manager")
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
business, _ = _full_chain(client, admin["headers"], owner_id=salesperson["id"])
resp = client.post(
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
)
assert resp.status_code == 200, resp.text
resp = client.get(
f"{_REPORTS}/business-stats",
params={"keyword": business["business_no"]},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
row = resp.json()["data"]["list"][0]
assert Decimal(str(row["performance_amount"])) == _ledger_sum(admin["headers"], _month())
def test_导出_excel含签字栏且入操作日志(admin, client: TestClient):
"""导出是文件流(非统一信封);模板含复核/签字栏;行为入操作日志。"""
from io import BytesIO
from openpyxl import load_workbook
person = _person(client, admin["headers"])
_full_chain(client, admin["headers"], owner_id=person["id"])
resp = client.get(
f"{_REPORTS}/personal-detail/export",
params={"settlement_month": _month()},
headers=admin["headers"],
)
assert resp.status_code == 200, resp.text
assert resp.headers["content-type"].startswith(
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
workbook = load_workbook(BytesIO(resp.content))
text = "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
assert "个人绩效工资明细表" in text
assert "财务复核" in text
assert "领导签字" in text
from sqlalchemy import select
from app.core.db import get_session_factory
from app.models import OperationLog
factory = get_session_factory()
with factory() as session:
log = session.execute(
select(OperationLog)
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
.order_by(OperationLog.id.desc())
).scalars().first()
assert log is not None
def test_权限_普通用户403文案(admin, normal, client: TestClient):
"""报表限管理员/财务/领导;普通用户被后端拦截且文案正确。"""
resp = client.get(f"{_REPORTS}/personal-detail", headers=normal["headers"])
assert resp.status_code == 403
assert resp.json()["message"] == "无权访问报表统计"
+211
View File
@@ -0,0 +1,211 @@
"""报表辅助纯函数与 Excel 模板构建单元测试(M4 ⑤)。
这组用例守的是「不测会静默出错」的三类点:
- 区间分桶边界错一档 → 金额进错桶,但报表仍然「能打开」,没人会发现
- 标签映射漏一个枚举值 → 报表少一类数据或显示英文编码
- 导出模板缺签字栏 / 金额被转 float / 文本公式注入 → 交给财务的签字稿失真
"""
import io
from datetime import date
from decimal import Decimal
from openpyxl import load_workbook
from app.core import report_meta
from app.core.constants import CUSTOMER_TYPES, PRODUCT_CATEGORIES
from app.services import report_export
class Test区间分桶:
"""回款天数 → 8 档区间(含 ≤0 与无天数两个兜底桶)。"""
def test_边界值全部落入正确区间(self):
cases = [
(None, "none"),
(-5, "le0"),
(0, "le0"),
(1, "1-30"),
(30, "1-30"),
(31, "31-60"),
(60, "31-60"),
(61, "61-90"),
(90, "61-90"),
(91, "91-180"),
(180, "91-180"),
(181, "181-360"),
(360, "181-360"),
(361, "361+"),
(1000, "361+"),
]
for days, code in cases:
assert report_meta.bucket_of(days).code == code, f"days={days}"
def test_八档且编码唯一(self):
buckets = report_meta.get_payment_day_buckets()
assert len(buckets) == 8
assert len({b.code for b in buckets}) == 8
class Test标签覆盖:
"""标签键集合必须覆盖业务常量——防常量新增后报表静默少算/显示编码。"""
def test_客户类型标签覆盖全部类型(self):
assert set(report_meta.CUSTOMER_TYPE_LABELS) >= CUSTOMER_TYPES
def test_产品类别标签覆盖常量与混合业务(self):
assert set(report_meta.PRODUCT_CATEGORY_LABELS) >= PRODUCT_CATEGORIES
assert "mixed" in report_meta.PRODUCT_CATEGORY_LABELS
def _build(code: str, rows: list[dict], buckets: dict | None = None) -> bytes:
return report_export.build_workbook(
code=code,
title="测试报表",
filters="统计月份 2026-10",
rows=rows,
summary=None,
buckets=buckets,
template_version="V1.0",
operator_name="admin",
company_name="衍水集团",
)
def _sheet_text(content: bytes) -> str:
workbook = load_workbook(io.BytesIO(content))
return "\n".join(
str(cell.value)
for row in workbook.active.iter_rows()
for cell in row
if cell.value is not None
)
def _personal_row(**overrides) -> dict:
row = {
"business_no": "YW001",
"ship_date": date(2026, 10, 1),
"receipt_date": date(2026, 10, 20),
"payment_days": 20,
"product_category": "municipal_water",
"customer_type": "personal",
"role_name": "营销员",
"rate_text": "88/9000",
"weight_r": 100,
"performance_amount": Decimal("39.11"),
"payable_amount": Decimal("39.11"),
}
row.update(overrides)
return row
class TestExcel模板:
"""导出稿是交给财务复核与领导签字的,结构缺失等同于没有模板。"""
def test_页眉标题签字栏齐全(self):
text = _sheet_text(_build("personal-detail", [_personal_row()]))
assert "衍水集团" in text
assert "测试报表" in text
assert "统计月份 2026-10" in text
assert "模板版本:V1.0" in text
assert "财务复核" in text
assert "领导签字" in text
def test_空数据不炸且给提示行(self):
text = _sheet_text(_build("personal-detail", []))
assert "本筛选条件无数据" in text
assert "财务复核" in text
def test_金额为数值单元格且两位格式(self):
# openpyxl 写入 Decimal 后读回为 int/float(Excel 单元格本身是数值),
# 这里断言「是数值而非文本」+ 显示格式;计算精度在服务层 Decimal 侧保证
content = _build("personal-detail", [_personal_row()])
workbook = load_workbook(io.BytesIO(content))
sheet = workbook.active
amount_cells = [
cell
for row in sheet.iter_rows()
for cell in row
if isinstance(cell.value, (int, float))
and Decimal(str(cell.value)) == Decimal("39.11")
]
assert amount_cells, "未找到金额数值单元格(Decimal 被转成了文本?)"
assert amount_cells[0].number_format == "#,##0.00"
def test_合计行等于明细之和且标签只出现一次(self):
rows = [
_personal_row(performance_amount=Decimal("10.00"), payable_amount=Decimal("10.00")),
_personal_row(performance_amount=Decimal("5.50"), payable_amount=Decimal("5.50")),
]
content = _build("personal-detail", rows)
workbook = load_workbook(io.BytesIO(content))
cells = [cell for row in workbook.active.iter_rows() for cell in row]
values = [
Decimal(str(cell.value))
for cell in cells
if isinstance(cell.value, (int, float))
]
assert Decimal("15.50") in values
# 「合计」标签只写一次(写满每个文本列是回归缺陷)
assert sum(1 for cell in cells if cell.value == "合计") == 1
def test_文本公式注入被前置单引号(self):
evil = "=HYPERLINK(\"http://evil\")"
content = _build("personal-detail", [_personal_row(business_no=evil)])
workbook = load_workbook(io.BytesIO(content))
values = [
cell.value
for row in workbook.active.iter_rows()
for cell in row
if isinstance(cell.value, str)
]
assert any(value.startswith("'=") for value in values), "公式注入未转义"
def test_回款统计附两个维度对比sheet(self):
buckets = {
"by_customer_type": [
{
"label": "个人户",
"receipt_amount": Decimal("100.00"),
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
"ledger_count": 1,
}
],
"by_product_category": [],
}
content = _build(
"payment-stats",
[
{
"label": "1-30 天",
"receipt_amount": Decimal("100.00"),
"receipt_count": 1,
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
"ledger_count": 1,
}
],
buckets=buckets,
)
workbook = load_workbook(io.BytesIO(content))
assert workbook.sheetnames == ["回款统计", "客户类型对比", "产品类别对比"]
def test_业务统计状态转中文(self):
rows = [
{
"business_no": "YW001",
"business_date": date(2026, 10, 1),
"customer_name": "测试客户",
"owner_personnel_name": "张三",
"status": 2,
"total_sale_amount": Decimal("100.00"),
"total_ship_amount": Decimal("100.00"),
"total_received_amount": Decimal("100.00"),
"performance_amount": Decimal("1.00"),
"payable_amount": Decimal("1.00"),
}
]
text = _sheet_text(_build("business-stats", rows))
assert "已完成" in text
@@ -42,7 +42,11 @@
## 二、后端任务
### T1 常量与元数据(`core/constants.py` 改 + `services/report_meta.py` 新)
### T1 常量与元数据(`core/constants.py` 改 + `core/report_meta.py` 新)
> 实施修订(2026-10-19):`report_meta` 放在 `core/` 而非 `services/`——仓储层
> 的区间下钻需要 `DayBucket` 类型,放 services 会让 repository 反向依赖
> service 层(违反六层红线)。
- `MODULE_REPORT = "报表统计"`、`ACTION_EXPORT = "导出"`
- `report_meta.py`(叶子模块,禁 app 内导入防循环依赖):`DayBucket` frozen dataclass + `PAYMENT_DAY_BUCKETS` 8 档(≤0 天(先款后货)/1-30/31-60/61-90/91-180/181-360/361+/无天数)、`CUSTOMER_TYPE_LABELS`、`PRODUCT_CATEGORY_LABELS`(含 mixed)、`REPORT_TEMPLATE_VERSION = "V1.0"`、`REPORT_EXPORT_MAX_ROWS = 20000`
Binary file not shown.
+300
View File
@@ -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())
+228
View File
@@ -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
}
+35
View File
@@ -81,3 +81,38 @@ body {
.toolbar {
margin-bottom: 12px;
}
/*
* 确认框(ElMessageBox)统一视觉
*
* Element Plus 默认把 warning 图标渲染在正文左侧、与正文并排成两列:
* 多行正文时图标悬在文字块中间,左侧留出一列空白,标题行反而是空的;
* 实心黄圆也与本站「中性灰白 + 靛蓝点缀」体系冲突。
* 这里去掉调用处的 type,把图标改画在标题行左侧:18px 描边样式,
* 深琥珀 #b7791f + 浅琥珀底 #fbf3e4(警示语义,饱和度压到与靛蓝同级);
* 正文与标题文字左对齐,图标单独悬挂在左侧。图标为 CSS 伪元素,天然不进无障碍树,
* 属于装饰元素,语义由标题文字承载。
* MessageBox 挂载在 body 下,scoped 样式够不到,因此放在全局文件。
*/
.ys-confirm-box .el-message-box__title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--ys-ink);
}
.ys-confirm-box .el-message-box__title::before {
content: '';
flex: none;
width: 18px;
height: 18px;
background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='18' height='18' viewBox='0 0 18 18' fill='none'%3E%3Ccircle cx='9' cy='9' r='8.25' fill='%23FBF3E4'/%3E%3Ccircle cx='9' cy='9' r='7.4' stroke='%23B7791F' stroke-width='1.3'/%3E%3Cpath d='M9 5.3V9.8' stroke='%23B7791F' stroke-width='1.5' stroke-linecap='round'/%3E%3Ccircle cx='9' cy='12.4' r='0.95' fill='%23B7791F'/%3E%3C/svg%3E")
center / 18px 18px no-repeat;
}
.ys-confirm-box .el-message-box__message {
/* 与标题文字对齐,让图标单独悬挂在左侧:18px 图标 + 8px gap */
padding-left: 26px;
color: var(--ys-ink-2);
}
@@ -0,0 +1,57 @@
/*
* 表格操作列统一样式。
*
* 用法:用 <div class="table-actions"> 包住该行的操作按钮;破坏性动作
* (删除/禁用等)前插入 <span class="table-actions__divider"></span> 分组。
* 列宽须容纳整行动作的自然宽度,否则 Element Plus 会把末尾按钮折到第二行。
*/
.table-actions {
display: flex;
align-items: center;
justify-content: center;
gap: 14px;
white-space: nowrap;
}
/* 间距统一交给 gap,抵消相邻按钮默认的 12px 外边距 */
.table-actions .el-button + .el-button {
margin-left: 0;
}
/*
* 带 title 提示或 tooltip 的按钮常包在 span 里。行内盒的基线留白会让包装
* 按钮比直接子按钮低约 1.7px,改为 flex 后与整行严格垂直对齐。
*/
.table-actions > span:not(.table-actions__divider) {
display: flex;
align-items: center;
}
/* 悬停加深并加下划线:Element 默认悬停变浅,浅靛蓝在表格底色上对比度不足 */
.table-actions .el-button.is-link:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.table-actions .el-button.is-link.el-button--primary:hover {
color: var(--el-color-primary-dark-2);
}
.table-actions .el-button.is-link.el-button--success:hover {
color: var(--el-color-success-dark-2);
}
.table-actions .el-button.is-link.el-button--warning:hover {
color: var(--el-color-warning-dark-2);
}
.table-actions .el-button.is-link.el-button--danger:hover {
color: var(--el-color-danger-dark-2);
}
.table-actions__divider {
flex: none;
width: 1px;
height: 12px;
background-color: var(--ys-line);
}
+3 -1
View File
@@ -29,7 +29,9 @@ export function useConfirmAction() {
}): Promise<boolean> {
try {
await ElMessageBox.confirm(options.confirmText, options.title ?? '操作确认', {
type: 'warning',
// 不传 type:Element Plus 的 warning 图标会渲染在正文左侧独立成列,
// 标题行反被留空。图标改由全局样式画在标题行内(.ys-confirm-box)。
customClass: 'ys-confirm-box',
confirmButtonText: options.confirmButtonText ?? '确定',
cancelButtonText: '取消',
})
+1
View File
@@ -8,5 +8,6 @@ import pinia from './store'
import 'element-plus/dist/index.css'
import './assets/styles/index.css'
import './assets/styles/table-actions.css'
createApp(App).use(pinia).use(router).use(ElementPlus, { locale: zhCn }).mount('#app')
+58
View File
@@ -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' },
]
+10 -5
View File
@@ -398,12 +398,17 @@ onMounted(() => {
<el-table-column label="归属人员" min-width="110">
<template #default="{ row }">{{ row.owner_personnel_name ?? '(无归属)' }}</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" width="260" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">归属变更</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openOwnership(row)">
归属变更
</el-button>
<el-button link type="primary" size="small" @click="openLogs(row)">变更记录</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+12 -7
View File
@@ -452,14 +452,19 @@ onMounted(() => {
<StatusTag :value="row.status" active-text="在职" inactive-text="离职" />
</template>
</el-table-column>
<el-table-column label="操作" width="230" fixed="right">
<el-table-column label="操作" width="270" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openSubTables(row)">区域与计划</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '设为离职' : '设为在职' }}
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openSubTables(row)">
区域与计划
</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '设为离职' : '设为在职' }}
</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+9 -6
View File
@@ -269,13 +269,16 @@ onMounted(() => {
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" size="small" @click="handleToggleStatus(row)">
{{ row.status === 1 ? '停用' : '启用' }}
</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
@@ -690,37 +690,46 @@ onMounted(() => {
<span class="cell-amount">{{ row.total_received_amount ?? '—' }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="300" fixed="right">
<el-table-column label="操作" width="300" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
<el-tooltip content="已完成或已终止的业务禁止编辑" :disabled="row.status === 1" placement="top">
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip content="终态不可再变更" :disabled="row.status === 1" placement="top">
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openStatus(row)"
>
状态
</el-button>
</span>
</el-tooltip>
<el-button link type="primary" size="small" @click="openOwnership(row)">归属</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openDrawer(row)">详情</el-button>
<el-tooltip
content="已完成或已终止的业务禁止编辑"
:disabled="row.status === 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip content="终态不可再变更" :disabled="row.status === 1" placement="top">
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.status !== 1"
@click="openStatus(row)"
>
状态
</el-button>
</span>
</el-tooltip>
<el-button link type="primary" size="small" @click="openOwnership(row)">
归属
</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+5 -2
View File
@@ -282,8 +282,11 @@ onMounted(() => {
<el-table-column prop="sort_order" label="排序" width="70" align="center" />
<el-table-column label="操作" width="110" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="removeGrade(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
+38 -35
View File
@@ -346,42 +346,45 @@ onMounted(() => {
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<el-table-column label="操作" width="130" fixed="right" align="center">
<template #default="{ row }">
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.settlement_status === 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="danger"
size="small"
:disabled="row.settlement_status === 1"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
<div class="table-actions">
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="primary"
size="small"
:disabled="row.settlement_status === 1"
@click="openEdit(row)"
>
编辑
</el-button>
</span>
</el-tooltip>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-tooltip
content="已核算回款不可变更"
:disabled="row.settlement_status !== 1"
placement="top"
>
<span class="op-wrap">
<el-button
link
type="danger"
size="small"
:disabled="row.settlement_status === 1"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</el-tooltip>
</div>
</template>
</el-table-column>
</el-table>
@@ -337,10 +337,13 @@ onMounted(() => {
<span class="cell-amount">{{ row.total_amount }}</span>
</template>
</el-table-column>
<el-table-column label="操作" width="130" fixed="right">
<el-table-column label="操作" width="130" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span class="table-actions__divider" aria-hidden="true"></span>
<el-button link type="danger" size="small" @click="handleDelete(row)">删除</el-button>
</div>
</template>
</el-table-column>
</el-table>
@@ -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>
+411
View File
@@ -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>
@@ -258,31 +258,34 @@ onMounted(() => {
<StatusTag :value="row.status" />
</template>
</el-table-column>
<el-table-column label="操作" width="190" fixed="right">
<el-table-column label="操作" width="190" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="builtinHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="builtinHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="builtinHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span class="table-actions__divider" aria-hidden="true"></span>
<span :title="builtinHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</div>
</template>
</el-table-column>
</el-table>
+1 -1
View File
@@ -193,7 +193,7 @@ async function submitAll(): Promise<void> {
await ElMessageBox.confirm(
`共 ${pendingList.value.length} 项变更,${effectiveText.value}。提交后逐项留痕,未生效前可整批撤回。`,
'提交确认',
{ confirmButtonText: '提交', cancelButtonText: '再想想', type: 'warning' },
{ confirmButtonText: '提交', cancelButtonText: '再想想', customClass: 'ys-confirm-box' },
)
} catch {
return
+27 -24
View File
@@ -224,31 +224,34 @@ onMounted(load)
<el-table-column label="用户数" width="90" align="center">
<template #default="{ row }">{{ row.user_count }}</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<el-table-column label="操作" width="170" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span :title="deleteHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row) || row.user_count > 0"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<span :title="isBuiltin(row) ? '内置管理员角色不可禁用' : ''">
<el-button
link
type="warning"
size="small"
:disabled="isBuiltin(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span class="table-actions__divider" aria-hidden="true"></span>
<span :title="deleteHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isBuiltin(row) || row.user_count > 0"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</div>
</template>
</el-table-column>
</el-table>
+29 -26
View File
@@ -331,34 +331,37 @@ onMounted(() => {
<el-table-column label="最后登录" min-width="170">
<template #default="{ row }">{{ formatDateTime(row.last_login_time) }}</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<el-table-column label="操作" width="250" fixed="right" align="center">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openReset(row)">
重置密码
</el-button>
<span :title="selfHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isSelf(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
<div class="table-actions">
<el-button link type="primary" size="small" @click="openEdit(row)">编辑</el-button>
<el-button link type="primary" size="small" @click="openReset(row)">
重置密码
</el-button>
</span>
<span :title="selfHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isSelf(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
<span :title="selfHint(row)">
<el-button
link
type="warning"
size="small"
:disabled="isSelf(row)"
@click="handleToggleStatus(row)"
>
{{ row.status === 1 ? '禁用' : '启用' }}
</el-button>
</span>
<span class="table-actions__divider" aria-hidden="true"></span>
<span :title="selfHint(row)">
<el-button
link
type="danger"
size="small"
:disabled="isSelf(row)"
@click="handleDelete(row)"
>
删除
</el-button>
</span>
</div>
</template>
</el-table-column>
</el-table>