Compare commits
4
Commits
cc4c51359c
...
b7cfdfa50a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7cfdfa50a | ||
|
|
04a1a0d307 | ||
|
|
2a1939d2e7 | ||
|
|
cfd2a196f2 |
No files matched your search
@@ -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 是安全问题,必须启动即失败。
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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.
@@ -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()
|
||||
)
|
||||
@@ -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):
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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` 运行 |
|
||||
|
||||
## 四、测试发现并修复的缺陷
|
||||
|
||||
|
||||
@@ -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"] == "无权访问报表统计"
|
||||
@@ -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.
@@ -0,0 +1,300 @@
|
||||
"""报表统计端到端验证(M4 ⑤:四张报表 + 打印视图)。
|
||||
|
||||
覆盖真实浏览器链路(接口测试守不住的部分):
|
||||
- 「报表统计」组四页菜单可见、可进入
|
||||
- 个人明细:API 预置的已核算业务出现在列表;导出触发真实下载
|
||||
- 部门汇总:经理团队行出现且团队基数非 0;全公司 5% 卡片可见
|
||||
- 业务统计:关键字筛选命中;三列统计非空
|
||||
- 回款统计:8 档区间表;目标桶(61-90 天)有金额;空桶显示 0
|
||||
- 打印视图:独立路由带出报表与签字栏;window.print 被调用
|
||||
|
||||
前置(缺一不可):
|
||||
docker compose up -d
|
||||
cd backend && uv run uvicorn app.main:app --port 8000
|
||||
cd frontend && pnpm dev
|
||||
cd frontend && E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/report_flow.py
|
||||
|
||||
数据说明:核算后数据不可删(业务规则),按 `e2e_` 前缀留档开发库;
|
||||
每次运行用随机后缀隔离,断言以本次业务编号定位。
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
from common import (
|
||||
BASE_URL,
|
||||
E2EFailure,
|
||||
collect_console_errors,
|
||||
current_path,
|
||||
fail,
|
||||
login,
|
||||
)
|
||||
|
||||
ADMIN = "admin"
|
||||
SUFFIX = f"{int(time.time())}"
|
||||
console_errors: list[str] = []
|
||||
MONTH = time.strftime("%Y-%m")
|
||||
|
||||
|
||||
def _api(method: str, path: str, token: str, body: dict | None = None) -> dict:
|
||||
"""带令牌的轻量 API 调用(urllib,避免额外依赖)。"""
|
||||
request = urllib.request.Request(
|
||||
f"{BASE_URL}{path}",
|
||||
method=method,
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
data=json.dumps(body).encode() if body is not None else None,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=20) as response:
|
||||
payload = json.loads(response.read().decode())
|
||||
except urllib.error.HTTPError as exc:
|
||||
detail = exc.read().decode()[:200]
|
||||
raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc
|
||||
if payload.get("code") != 200:
|
||||
raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}")
|
||||
return payload["data"]
|
||||
|
||||
|
||||
def step_login(page: Page, admin_password: str) -> None:
|
||||
login(page, ADMIN, admin_password)
|
||||
print("✓ 1. 登录")
|
||||
|
||||
|
||||
def step_setup_via_api(page: Page) -> dict:
|
||||
"""预置:经理 + 业务员 → 客户/业务 → 发货(60 天前)→ 回款 → 核算。"""
|
||||
token = page.evaluate("localStorage.getItem('ys_token')")
|
||||
if not token:
|
||||
fail("API 预置", "localStorage 中未取到 ys_token")
|
||||
|
||||
roles = _api("GET", "/api/v1/system/business-roles?pageNum=1&pageSize=50", token)["list"]
|
||||
salesperson_role = next((r["id"] for r in roles if r["role_code"] == "salesperson"), None)
|
||||
manager_role = next((r["id"] for r in roles if r["role_code"] == "sales_manager"), None)
|
||||
if salesperson_role is None or manager_role is None:
|
||||
fail("API 预置", "未找到 salesperson / sales_manager 岗位种子")
|
||||
|
||||
manager = _api(
|
||||
"POST",
|
||||
"/api/v1/base/personnel",
|
||||
token,
|
||||
{
|
||||
"personnel_name": f"e2e_报表现经理_{SUFFIX}",
|
||||
"business_role_id": manager_role,
|
||||
"salary_standard": "3000.00",
|
||||
},
|
||||
)
|
||||
person = _api(
|
||||
"POST",
|
||||
"/api/v1/base/personnel",
|
||||
token,
|
||||
{
|
||||
"personnel_name": f"e2e_报表现业务员_{SUFFIX}",
|
||||
"business_role_id": salesperson_role,
|
||||
"salary_standard": "3000.00",
|
||||
"manager_id": manager["id"],
|
||||
},
|
||||
)
|
||||
customer = _api(
|
||||
"POST",
|
||||
"/api/v1/base/customers",
|
||||
token,
|
||||
{
|
||||
"company_name": f"e2e_报表客户_{SUFFIX}",
|
||||
"owner_name": "张老板",
|
||||
"customer_type": "personal",
|
||||
},
|
||||
)
|
||||
business = _api(
|
||||
"POST",
|
||||
"/api/v1/business/businesses",
|
||||
token,
|
||||
{
|
||||
"customer_id": customer["id"],
|
||||
"business_date": f"{MONTH}-01",
|
||||
"owner_personnel_id": person["id"],
|
||||
"items": [
|
||||
{
|
||||
"product_name": f"e2e_报表产品_{SUFFIX}",
|
||||
"product_category": "municipal_water",
|
||||
"quantity": "100",
|
||||
"sale_price": "100.00",
|
||||
"cost_amount": "3000.00",
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
from datetime import date, timedelta
|
||||
|
||||
ship_date = (date.today() - timedelta(days=60)).isoformat()
|
||||
shipment = _api(
|
||||
"POST",
|
||||
"/api/v1/business/shipments",
|
||||
token,
|
||||
{
|
||||
"business_id": business["id"],
|
||||
"ship_date": ship_date,
|
||||
"items": [{"product_name": "e2e_报表发货", "quantity": "1", "sale_price": "1.00"}],
|
||||
},
|
||||
)
|
||||
receipt = _api(
|
||||
"POST",
|
||||
"/api/v1/business/receipts",
|
||||
token,
|
||||
{
|
||||
"business_id": business["id"],
|
||||
"receipt_date": date.today().isoformat(),
|
||||
"amount": "10000.00",
|
||||
"shipment_id": shipment["id"],
|
||||
},
|
||||
)
|
||||
_api("POST", f"/api/v1/calc/receipts/{receipt['id']}/calc", token)
|
||||
print(f"✓ 2. API 预置 业务 {business['business_no']} 已核算")
|
||||
return {
|
||||
"month": MONTH,
|
||||
"business_no": business["business_no"],
|
||||
"personnel_name": person["personnel_name"],
|
||||
"manager_name": manager["personnel_name"],
|
||||
}
|
||||
|
||||
|
||||
def step_personal_detail(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/personal-detail", wait_until="networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
if current_path(page) != "/reports/personal-detail":
|
||||
fail("导航", f"未进入个人绩效明细页,当前 {page.url}")
|
||||
|
||||
# 月份筛选:Element Plus 月份选择器可直接键入 YYYY-MM
|
||||
month_input = page.locator(".filters input").first
|
||||
month_input.fill(ctx["month"])
|
||||
month_input.press("Enter")
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
if row.count() == 0:
|
||||
fail("个人明细", f"未找到业务 {ctx['business_no']} 的明细行")
|
||||
print("✓ 3. 个人明细 列表命中本次核算业务")
|
||||
|
||||
with page.expect_download(timeout=20000) as download_info:
|
||||
page.locator("button:has-text('导出 Excel')").first.click()
|
||||
download = download_info.value
|
||||
if not download.suggested_filename.endswith(".xlsx"):
|
||||
fail("导出", f"下载文件名非 xlsx:{download.suggested_filename}")
|
||||
print(f"✓ 4. 导出下载 {download.suggested_filename}")
|
||||
|
||||
|
||||
def step_department_summary(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/department-summary", wait_until="networkidle")
|
||||
page.wait_for_timeout(900)
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["manager_name"]).first
|
||||
if row.count() == 0:
|
||||
fail("部门汇总", f"未找到经理 {ctx['manager_name']} 的团队行")
|
||||
if page.locator(".company-card").count() == 0:
|
||||
fail("部门汇总", "全公司 5% 卡片未渲染")
|
||||
print("✓ 5. 部门汇总 团队行与全公司卡片可见")
|
||||
|
||||
|
||||
def step_business_stats(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/business-stats", wait_until="networkidle")
|
||||
page.wait_for_timeout(800)
|
||||
keyword = page.locator(".filters input").first
|
||||
keyword.fill(ctx["business_no"])
|
||||
keyword.press("Enter")
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(800)
|
||||
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
if row.count() == 0:
|
||||
fail("业务统计", f"未找到业务 {ctx['business_no']}")
|
||||
print("✓ 6. 业务统计 关键字筛选命中")
|
||||
|
||||
|
||||
def step_payment_stats(page: Page, ctx: dict) -> None:
|
||||
page.goto(f"{BASE_URL}/reports/payment-stats", wait_until="networkidle")
|
||||
page.wait_for_timeout(900)
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
rows = page.locator(".el-table__row")
|
||||
if rows.count() != 8:
|
||||
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
|
||||
bucket = page.locator(".el-table__row", has_text="61-90 天").first
|
||||
if bucket.count() == 0:
|
||||
fail("回款统计", "未找到 61-90 天区间行")
|
||||
bucket_text = bucket.inner_text()
|
||||
if "10000.00" not in bucket_text:
|
||||
fail("回款统计", f"61-90 天桶回款金额不符:{bucket_text[:120]}")
|
||||
print("✓ 7. 回款统计 8 档区间与目标桶金额正确")
|
||||
|
||||
|
||||
def step_print_view(page: Page, ctx: dict) -> None:
|
||||
page.add_init_script("window.print = () => { window.__printed = true }")
|
||||
page.goto(
|
||||
f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}",
|
||||
wait_until="networkidle",
|
||||
)
|
||||
page.wait_for_timeout(1200)
|
||||
if page.locator(".sheet-table").count() == 0:
|
||||
fail("打印视图", "报表表格未渲染")
|
||||
text = page.locator(".print-page").inner_text()
|
||||
if "财务复核" not in text or "领导签字" not in text:
|
||||
fail("打印视图", "签字栏缺失")
|
||||
page.locator("button:has-text('打印')").first.click()
|
||||
page.wait_for_timeout(400)
|
||||
if page.evaluate("window.__printed") is not True:
|
||||
fail("打印视图", "window.print 未被调用")
|
||||
print("✓ 8. 打印视图 表格/签字栏/打印调用均正常")
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
collect_console_errors(page, console_errors)
|
||||
admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "")
|
||||
if not admin_password:
|
||||
fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)")
|
||||
|
||||
step_login(page, admin_password)
|
||||
ctx = step_setup_via_api(page)
|
||||
step_personal_detail(page, ctx)
|
||||
step_department_summary(page, ctx)
|
||||
step_business_stats(page, ctx)
|
||||
step_payment_stats(page, ctx)
|
||||
step_print_view(page, ctx)
|
||||
|
||||
if console_errors:
|
||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=True)
|
||||
page = browser.new_page(viewport={"width": 1600, "height": 1000})
|
||||
page.set_default_timeout(15000)
|
||||
try:
|
||||
run(page)
|
||||
except E2EFailure as exc:
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ {exc}")
|
||||
print(" 已保存截图 e2e/failure.png")
|
||||
return 1
|
||||
except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因
|
||||
page.screenshot(path="e2e/failure.png", full_page=True)
|
||||
print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}")
|
||||
return 1
|
||||
finally:
|
||||
browser.close()
|
||||
print("\n全部通过。")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,228 @@
|
||||
import service, { request } from './request'
|
||||
import type { PageData } from './types'
|
||||
|
||||
/** 报表分页:标准分页四字段 + 全筛选合计(summary 非当前页合计) */
|
||||
export interface ReportSummary {
|
||||
row_count: number
|
||||
performance_total?: string | null
|
||||
payable_total?: string | null
|
||||
converted_profit_total?: string | null
|
||||
ship_total?: string | null
|
||||
received_total?: string | null
|
||||
sale_total?: string | null
|
||||
}
|
||||
|
||||
export interface ReportPage<T> extends PageData<T> {
|
||||
summary: ReportSummary
|
||||
}
|
||||
|
||||
/** 个人绩效明细行(金额/比例一律字符串,前端不做数值运算) */
|
||||
export interface PersonalDetailRow {
|
||||
ledger_id: number
|
||||
business_id: number
|
||||
business_no: string
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
role_name: string
|
||||
customer_type: string
|
||||
product_category: string
|
||||
ship_date: string | null
|
||||
receipt_date: string
|
||||
payment_days: number | null
|
||||
rate_text: string
|
||||
weight_r: number | null
|
||||
payment_weight: string | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
converted_profit: string | null
|
||||
is_probation: number
|
||||
missing_cost: number
|
||||
calc_version: number
|
||||
settlement_id: number | null
|
||||
}
|
||||
|
||||
export interface DepartmentMemberRow {
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
is_probation: number
|
||||
performance_sum: string | null
|
||||
payable_sum: string | null
|
||||
adjust_extra: string
|
||||
adjust_deduct: string
|
||||
adjust_net: string
|
||||
net_total: string | null
|
||||
ledger_ids: number[]
|
||||
}
|
||||
|
||||
export interface DepartmentTeamRow {
|
||||
manager_id: number
|
||||
manager_name: string
|
||||
manager_status: number
|
||||
member_count: number
|
||||
team_base: string
|
||||
team_share_amount: string
|
||||
company_share_amount: string
|
||||
company_base_ratio: string
|
||||
adjust_extra: string
|
||||
adjust_deduct: string
|
||||
adjust_net: string
|
||||
net_total: string
|
||||
frozen_amount: string | null
|
||||
aggregate_ledger_id: number | null
|
||||
members: DepartmentMemberRow[]
|
||||
}
|
||||
|
||||
export interface DepartmentSummary {
|
||||
settlement_month: string
|
||||
params: {
|
||||
team_text: string
|
||||
company_text: string
|
||||
manager_personal_included: number
|
||||
params_as_of: string
|
||||
} | null
|
||||
teams: DepartmentTeamRow[]
|
||||
unassigned: DepartmentMemberRow[]
|
||||
company: {
|
||||
team_base_total: string
|
||||
company_base: string
|
||||
company_share_pct: string
|
||||
company_share_amount: string
|
||||
} | null
|
||||
totals: Record<string, string>
|
||||
}
|
||||
|
||||
export interface BusinessStatsRow {
|
||||
business_id: number
|
||||
business_no: string
|
||||
business_date: string | null
|
||||
status: number
|
||||
customer_name: string | null
|
||||
owner_personnel_name: string | null
|
||||
total_sale_amount: string | null
|
||||
total_ship_amount: string | null
|
||||
total_received_amount: string | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
}
|
||||
|
||||
export interface BucketRow {
|
||||
code: string
|
||||
label: string
|
||||
min_days: number | null
|
||||
max_days: number | null
|
||||
receipt_amount: string | null
|
||||
receipt_count: number
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
ledger_count: number
|
||||
}
|
||||
|
||||
export interface GroupRow {
|
||||
code: string
|
||||
label: string
|
||||
receipt_amount: string | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
ledger_count: number
|
||||
}
|
||||
|
||||
export interface PaymentStats {
|
||||
settlement_month: string | null
|
||||
buckets: BucketRow[]
|
||||
by_customer_type: GroupRow[]
|
||||
by_product_category: GroupRow[]
|
||||
totals: Record<string, string | number>
|
||||
}
|
||||
|
||||
export interface PaymentDetailRow {
|
||||
ledger_id: number
|
||||
receipt_id: number | null
|
||||
receipt_batch_no: string | null
|
||||
amount: string | null
|
||||
business_id: number
|
||||
business_no: string
|
||||
personnel_id: number
|
||||
personnel_name: string
|
||||
customer_type: string
|
||||
product_category: string
|
||||
ship_date: string | null
|
||||
receipt_date: string
|
||||
payment_days: number | null
|
||||
performance_amount: string | null
|
||||
payable_amount: string | null
|
||||
}
|
||||
|
||||
/** 报表查询通用筛选(各页按需取用) */
|
||||
export interface ReportFilterParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
settlement_month?: string
|
||||
personnel_id?: number
|
||||
customer_type?: string
|
||||
product_category?: string
|
||||
role_code?: string
|
||||
manager_id?: number
|
||||
keyword?: string
|
||||
customer_id?: number
|
||||
owner_personnel_id?: number
|
||||
status?: number
|
||||
date_from?: string
|
||||
date_to?: string
|
||||
bucket?: string
|
||||
}
|
||||
|
||||
export function listPersonalDetail(
|
||||
params: ReportFilterParams,
|
||||
): Promise<ReportPage<PersonalDetailRow>> {
|
||||
return request({ url: '/v1/reports/personal-detail', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getDepartmentSummary(params: ReportFilterParams): Promise<DepartmentSummary> {
|
||||
return request({ url: '/v1/reports/department-summary', method: 'get', params })
|
||||
}
|
||||
|
||||
export function listBusinessStats(
|
||||
params: ReportFilterParams,
|
||||
): Promise<ReportPage<BusinessStatsRow>> {
|
||||
return request({ url: '/v1/reports/business-stats', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getPaymentStats(params: ReportFilterParams): Promise<PaymentStats> {
|
||||
return request({ url: '/v1/reports/payment-stats', method: 'get', params })
|
||||
}
|
||||
|
||||
export function listPaymentDetail(
|
||||
params: ReportFilterParams,
|
||||
): Promise<ReportPage<PaymentDetailRow>> {
|
||||
return request({ url: '/v1/reports/payment-detail', method: 'get', params })
|
||||
}
|
||||
|
||||
export function getPaymentDayBuckets(): Promise<
|
||||
{ code: string; label: string; min_days: number | null; max_days: number | null }[]
|
||||
> {
|
||||
return request({ url: '/v1/reports/payment-day-buckets', method: 'get' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出报表(文件流)。走 axios 实例(带令牌)而非 request 封装:响应是
|
||||
* xlsx 文件流,不是统一信封,拦截器已对 responseType=blob 直通。
|
||||
* 服务端文件名是 ASCII(HTTP 头 latin-1 限制),中文名由调用方 link.download 指定。
|
||||
*/
|
||||
export async function exportReport(
|
||||
code: 'personal-detail' | 'department-summary' | 'business-stats' | 'payment-stats',
|
||||
params: ReportFilterParams,
|
||||
): Promise<Blob> {
|
||||
const response = await service.get(`/v1/reports/${code}/export`, {
|
||||
params,
|
||||
responseType: 'blob',
|
||||
})
|
||||
return response.data as Blob
|
||||
}
|
||||
|
||||
/** 产品类别展示名:报表需额外覆盖「混合业务」(台账行可为 mixed) */
|
||||
export function reportCategoryLabel(code: string): string {
|
||||
if (code === 'mixed') return '混合业务'
|
||||
return { municipal_water: '市政水利', rail_transit: '轨道交通' }[code] ?? code
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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: '取消',
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
@@ -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' },
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user