fix(report): 报表统计修复批次——对账口径、导出审计与月份校验
- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
294d99a0d3
commit
ebb3a92f25
@ -14,10 +14,11 @@ from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Path, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, ValidationError
|
||||
|
||||
from app.api.deps import DbSession, RequestMetaDep, require_roles
|
||||
from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.response import ApiResponse, ok
|
||||
from app.models import User
|
||||
from app.schemas.report import (
|
||||
@ -167,12 +168,24 @@ def export_report(
|
||||
|
||||
|
||||
def _build_export_query(code: str, query: ExportQuery):
|
||||
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。"""
|
||||
"""导出参数 → 各报表自己的查询模型(department-summary 月份必填由模型校验)。
|
||||
|
||||
手工构造模型抛出的是 `pydantic.ValidationError`,不是 FastAPI 的
|
||||
`RequestValidationError`——不在这里转换就会落到全局 500。导出参数
|
||||
非法与查询接口一样应当是 400。
|
||||
"""
|
||||
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)
|
||||
model = {
|
||||
"personal-detail": PersonalDetailQuery,
|
||||
"department-summary": DepartmentSummaryQuery,
|
||||
"business-stats": BusinessStatsQuery,
|
||||
"payment-stats": PaymentStatsQuery,
|
||||
}[code]
|
||||
try:
|
||||
return model(**data)
|
||||
except ValidationError as exc:
|
||||
first = exc.errors()[0]
|
||||
location = ".".join(str(part) for part in first.get("loc", ()))
|
||||
message = first.get("msg", "参数错误")
|
||||
detail = f"{location} {message}" if location else message
|
||||
raise BadRequestError(f"参数错误:{detail}") from exc
|
||||
|
||||
@ -48,7 +48,10 @@ PRODUCT_CATEGORY_LABELS: dict[str, str] = {
|
||||
"mixed": "混合业务",
|
||||
}
|
||||
|
||||
REPORT_TEMPLATE_VERSION = "V1.0"
|
||||
# 业务状态中文标签:导出列、筛选页眉与前端标签共用同一份映射
|
||||
BUSINESS_STATUS_LABELS: dict[int, str] = {1: "进行中", 2: "已完成", 3: "已终止"}
|
||||
|
||||
REPORT_TEMPLATE_VERSION = "V1.2"
|
||||
# 导出超限即拒绝(需求「大数据量分批/超限提示」二选一取后者):普通
|
||||
# Workbook 全量载入内存,2 万行约数十 MB,超过该量级必须引导按月导出
|
||||
REPORT_EXPORT_MAX_ROWS = 20000
|
||||
|
||||
@ -7,10 +7,24 @@
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.core.response import PageQuery
|
||||
|
||||
|
||||
def _required_month(value: str) -> str:
|
||||
"""必填月份:非法直接抛 ValueError,由 FastAPI 压成 400。"""
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
def _optional_month(value: str | None) -> str | None:
|
||||
"""可选月份:空串规范为 None(axios 未剔除时也不会误报格式错误)。"""
|
||||
if value in (None, ""):
|
||||
return None
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
# ==== 查询入参 ====
|
||||
|
||||
|
||||
@ -23,6 +37,11 @@ class PersonalDetailQuery(PageQuery):
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
role_code: str | None = Field(default=None, max_length=32, description="岗位筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class DepartmentSummaryQuery(BaseModel):
|
||||
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
|
||||
@ -30,6 +49,11 @@ class DepartmentSummaryQuery(BaseModel):
|
||||
settlement_month: str = Field(min_length=7, max_length=7, description="归属月 YYYY-MM")
|
||||
manager_id: int | None = Field(default=None, ge=1, description="仅看某经理团队")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str) -> str:
|
||||
return _required_month(value)
|
||||
|
||||
|
||||
class BusinessStatsQuery(PageQuery):
|
||||
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
|
||||
@ -51,6 +75,11 @@ class PaymentStatsQuery(BaseModel):
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
class PaymentDetailQuery(PageQuery):
|
||||
"""回款区间下钻(桶编码必填,入参由 payment-stats 页面展开行携带)。"""
|
||||
@ -62,6 +91,11 @@ class PaymentDetailQuery(PageQuery):
|
||||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||||
product_category: str | None = Field(default=None, max_length=32, description="产品类别筛选")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str | None) -> str | None:
|
||||
return _optional_month(value)
|
||||
|
||||
|
||||
# ==== 响应出参 ====
|
||||
|
||||
@ -143,6 +177,13 @@ class DepartmentMemberRow(BaseModel):
|
||||
personnel_name: str
|
||||
role_code: str
|
||||
is_probation: int
|
||||
member_kind: str = Field(
|
||||
default="regular",
|
||||
description="行来源:regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单",
|
||||
)
|
||||
unassigned_reason: str | None = Field(
|
||||
default=None, description="未纳入团队基数的原因(仅未分配块)"
|
||||
)
|
||||
performance_sum: Decimal | None = None
|
||||
payable_sum: Decimal | None = None
|
||||
adjust_extra: Decimal = Field(default=Decimal("0"), description="调整单生效补发合计")
|
||||
@ -160,13 +201,26 @@ class DepartmentTeamRow(BaseModel):
|
||||
manager_status: int = Field(description="经理在职状态(1 在职 0 离职)")
|
||||
member_count: int
|
||||
team_base: Decimal = Field(description="团队基数(12.5% 的底,等于成员绩效合计)")
|
||||
team_share_amount: Decimal = Field(description="12.5% 团队分成")
|
||||
company_share_amount: Decimal = Field(description="5% 全公司分成(同一基数,各团队不互斥)")
|
||||
team_share_amount: Decimal = Field(description="12.5% 团队分成(分列展示)")
|
||||
company_share_amount: Decimal = Field(
|
||||
description="单经理 5% 名义值;该团队无月结聚合行时为 0(不发放)"
|
||||
)
|
||||
has_aggregate: bool = Field(
|
||||
default=False, description="本月是否有月结聚合行(决定是否发放 5%)"
|
||||
)
|
||||
aggregate_amount: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0",
|
||||
)
|
||||
team_subtotal: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="团队应发小计 = 聚合行金额 + 调整净额",
|
||||
)
|
||||
company_base_ratio: Decimal = Field(description="团队基数占全公司基数比例(除零为 0)")
|
||||
adjust_extra: Decimal = Decimal("0")
|
||||
adjust_deduct: Decimal = Decimal("0")
|
||||
adjust_net: Decimal = Decimal("0")
|
||||
net_total: Decimal = Field(description="团队净额 = 分成 + 调整净额")
|
||||
net_total: Decimal = Field(description="团队净额 = 团队应发小计")
|
||||
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
|
||||
aggregate_ledger_id: int | None = None
|
||||
members: list[DepartmentMemberRow] = Field(default_factory=list)
|
||||
@ -178,7 +232,50 @@ class DepartmentCompanyBlock(BaseModel):
|
||||
team_base_total: Decimal = Field(description="各团队基数之和(= 全部单笔绩效合计)")
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
company_share_pct: str = Field(description="分成比例参数原文")
|
||||
company_share_amount: Decimal = Field(description="5% 全公司分成")
|
||||
company_share_amount: Decimal = Field(description="单经理 5% 名义值")
|
||||
company_share_count: int = Field(
|
||||
default=0, description="计入应发的经理数(展示团队中有月结聚合行的数量)"
|
||||
)
|
||||
company_share_total: Decimal = Field(
|
||||
default=Decimal("0"), description="5% 合计 = 单份 × 计入次数"
|
||||
)
|
||||
|
||||
|
||||
class DepartmentReconciliation(BaseModel):
|
||||
"""部门汇总 · 对账块(导出/打印签字稿的合计口径,前端不自行求和)。
|
||||
|
||||
应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
|
||||
聚合行金额与月结 ``total_payable`` 同口径(12.5%+5% 一次舍入);
|
||||
``team_share_total`` 与 ``company_share_total`` 仅作分列展示,两者
|
||||
之和与聚合额的差记入 ``aggregate_rounding_diff``。
|
||||
"""
|
||||
|
||||
team_member_payable_total: Decimal = Field(description="展示团队成员的应付绩效合计")
|
||||
unassigned_payable_total: Decimal = Field(description="展示未分配人员的应付绩效合计")
|
||||
team_share_total: Decimal = Field(description="展示团队的 12.5% 分成合计(分列)")
|
||||
company_share_total: Decimal = Field(
|
||||
description="展示团队的全公司分成合计(每位有聚合行的经理各计一次,分列)"
|
||||
)
|
||||
aggregate_payable_total: Decimal = Field(
|
||||
description="展示团队的经理聚合行应发合计(12.5%+5% 一次舍入)"
|
||||
)
|
||||
aggregate_rounding_diff: Decimal = Field(
|
||||
default=Decimal("0"),
|
||||
description="聚合额 − 团队分成合计 − 全公司分成合计(分列舍入差)",
|
||||
)
|
||||
adjust_net_total: Decimal = Field(description="明细层调整净额合计(成员 + 未分配)")
|
||||
payable_total: Decimal = Field(description="应发合计 = 成员应付 + 未分配应付 + 聚合行 + 调整净额")
|
||||
team_base_total: Decimal = Field(description="展示团队基数合计")
|
||||
unassigned_performance_total: Decimal = Field(description="展示未分配人员绩效合计")
|
||||
other_team_base: Decimal = Field(
|
||||
default=Decimal("0"), description="未展示的基数(其他团队 + 全公司未分配)"
|
||||
)
|
||||
other_team_member_payable_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_company_share_total: Decimal = Field(default=Decimal("0"))
|
||||
other_team_aggregate_total: Decimal = Field(default=Decimal("0"))
|
||||
other_adjust_net_total: Decimal = Field(default=Decimal("0"))
|
||||
company_base: Decimal = Field(description="全公司基数(5% 的底)")
|
||||
|
||||
|
||||
class DepartmentSummaryOut(BaseModel):
|
||||
@ -191,6 +288,9 @@ class DepartmentSummaryOut(BaseModel):
|
||||
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
|
||||
)
|
||||
company: DepartmentCompanyBlock | None = None
|
||||
reconciliation: DepartmentReconciliation | None = Field(
|
||||
default=None, description="签字稿对账块(应发公式各项)"
|
||||
)
|
||||
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
|
||||
|
||||
|
||||
|
||||
@ -1,21 +1,17 @@
|
||||
"""月度结算的请求/响应模型(M3 ④)。"""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from app.core.month import validate_month
|
||||
from app.core.response import PageQuery
|
||||
|
||||
_MONTH_RE = re.compile(r"^\d{4}-\d{2}$")
|
||||
|
||||
|
||||
def _check_month(value: str) -> str:
|
||||
"""月份格式 YYYY-MM(settlement_month 全表唯一的维度)。"""
|
||||
if not _MONTH_RE.match(value):
|
||||
raise ValueError("月份格式须为 YYYY-MM")
|
||||
return value
|
||||
"""月份格式 YYYY-MM 且月份 01-12(settlement_month 全表唯一的维度)。"""
|
||||
return validate_month(value)
|
||||
|
||||
|
||||
class SettlementGenerateIn(BaseModel):
|
||||
@ -93,3 +89,8 @@ class AdjustmentCreateIn(BaseModel):
|
||||
description="金额(元,两位小数,恒正)",
|
||||
)
|
||||
reason: str = Field(min_length=1, max_length=255, description="扣补说明")
|
||||
|
||||
@field_validator("settlement_month")
|
||||
@classmethod
|
||||
def _v_settlement_month(cls, value: str) -> str:
|
||||
return _check_month(value)
|
||||
|
||||
@ -14,6 +14,8 @@ from fractions import Fraction
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.exceptions import BadRequestError
|
||||
from app.core.month import parse_month
|
||||
from app.models import Personnel
|
||||
|
||||
|
||||
@ -38,7 +40,11 @@ def month_end(month: str) -> date:
|
||||
红线)。单笔台账行按回款日取参(裁决 #11);聚合与月工资口径没有
|
||||
更细的业务日期,取该月最后可确定的生效时点。
|
||||
"""
|
||||
year, mon = int(month[:4]), int(month[5:7])
|
||||
try:
|
||||
year, mon = parse_month(month)
|
||||
except ValueError as exc:
|
||||
# schema 已拦住非法月份;内部调用绕过 schema 时也不能变成 500
|
||||
raise BadRequestError(str(exc)) from exc
|
||||
return date(year, mon, monthrange(year, mon)[1])
|
||||
|
||||
|
||||
|
||||
@ -19,7 +19,11 @@ 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
|
||||
from app.core.report_meta import (
|
||||
BUSINESS_STATUS_LABELS,
|
||||
CUSTOMER_TYPE_LABELS,
|
||||
PRODUCT_CATEGORY_LABELS,
|
||||
)
|
||||
|
||||
_HEADER_FILL = PatternFill("solid", fgColor="DDE3F0")
|
||||
_THIN = Side(style="thin", color="B0B7C3")
|
||||
@ -31,7 +35,6 @@ _AMOUNT_FORMAT = "#,##0.00"
|
||||
_DATE_FORMAT = "YYYY-MM-DD"
|
||||
_FORMULA_PREFIXES = ("=", "+", "-", "@", "\t")
|
||||
|
||||
BUSINESS_STATUS_LABELS = {1: "进行中", 2: "已完成", 3: "已终止"}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -44,6 +47,17 @@ class _Column:
|
||||
width: float
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Block:
|
||||
"""签字稿中的一个区块:独立表头、数据行与可选合计行。"""
|
||||
|
||||
title: str
|
||||
columns: tuple[_Column, ...]
|
||||
rows: list[dict]
|
||||
total: dict | None = None
|
||||
empty_text: str = "(无)"
|
||||
|
||||
|
||||
_COLUMNS: dict[str, tuple[_Column, ...]] = {
|
||||
"personal-detail": (
|
||||
_Column("business_no", "合同编号", "text", 20),
|
||||
@ -101,11 +115,30 @@ def build_workbook(
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
company_name: str,
|
||||
department: dict | None = None,
|
||||
) -> bytes:
|
||||
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。"""
|
||||
"""构建并返回 xlsx 字节流(业务数据只读,不触碰数据库)。
|
||||
|
||||
部门汇总是签字对账文件,走单 sheet 分块版式;其余报表仍是单表 +
|
||||
合计行。`department` 为 `department_summary` 的完整出参。
|
||||
"""
|
||||
workbook = Workbook()
|
||||
sheet = workbook.active
|
||||
sheet.title = _sheet_name(code)
|
||||
if code == "department-summary" and department is not None:
|
||||
_write_department_document(
|
||||
sheet,
|
||||
department,
|
||||
company_name=company_name,
|
||||
title=title,
|
||||
filters=filters,
|
||||
template_version=template_version,
|
||||
operator_name=operator_name,
|
||||
)
|
||||
buffer = BytesIO()
|
||||
workbook.save(buffer)
|
||||
return buffer.getvalue()
|
||||
|
||||
columns = _COLUMNS[code]
|
||||
_write_header(sheet, columns, company_name, title, filters, template_version, operator_name)
|
||||
if rows:
|
||||
@ -129,6 +162,7 @@ def build_workbook(
|
||||
extra,
|
||||
dim_title,
|
||||
company_name,
|
||||
filters,
|
||||
buckets.get(dim_key) or [],
|
||||
template_version,
|
||||
operator_name,
|
||||
@ -158,26 +192,15 @@ def _write_header(
|
||||
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}"
|
||||
]
|
||||
_write_document_header(
|
||||
sheet,
|
||||
len(columns),
|
||||
company_name,
|
||||
title,
|
||||
filters,
|
||||
template_version,
|
||||
operator_name,
|
||||
)
|
||||
_merge_row(sheet, len(columns))
|
||||
sheet.append([])
|
||||
|
||||
sheet.append([column.title for column in columns])
|
||||
header_row = sheet.max_row
|
||||
@ -189,6 +212,38 @@ def _write_header(
|
||||
sheet.freeze_panes = sheet.cell(row=header_row + 1, column=1)
|
||||
|
||||
|
||||
def _write_document_header(
|
||||
sheet,
|
||||
column_count: int,
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""公司名/标题/筛选条件/导出信息四行(分块文档与单表共用)。"""
|
||||
sheet.append([company_name])
|
||||
_merge_row(sheet, column_count)
|
||||
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, column_count)
|
||||
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, column_count)
|
||||
sheet.append(
|
||||
[
|
||||
f"导出时间:{datetime.now():%Y-%m-%d %H:%M:%S} | 导出人:{operator_name} | "
|
||||
f"模板版本:{template_version}"
|
||||
]
|
||||
)
|
||||
_merge_row(sheet, column_count)
|
||||
sheet.append([])
|
||||
|
||||
|
||||
def _merge_row(sheet, column_count: int) -> None:
|
||||
"""合并当前行(公司名/标题/筛选行跨全表宽)。"""
|
||||
row = sheet.max_row
|
||||
@ -289,11 +344,12 @@ def _write_dimension_sheet(
|
||||
sheet,
|
||||
title: str,
|
||||
company_name: str,
|
||||
filters: str,
|
||||
rows: list[dict],
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""维度对比 sheet(客户类型/产品类别)。"""
|
||||
"""维度对比 sheet(客户类型/产品类别),页眉筛选条件与主表一致。"""
|
||||
columns = (
|
||||
_Column("label", "维度", "text", 16),
|
||||
_Column("receipt_amount", "回款金额", "amount", 16),
|
||||
@ -301,9 +357,301 @@ def _write_dimension_sheet(
|
||||
_Column("payable_amount", "应付绩效", "amount", 14),
|
||||
_Column("ledger_count", "台账行数", "int", 10),
|
||||
)
|
||||
_write_header(sheet, columns, company_name, title, "全部", template_version, operator_name)
|
||||
_write_header(sheet, columns, company_name, title, filters, 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)
|
||||
|
||||
|
||||
# ==== 部门汇总签字稿(FR-030 / FR-032)====
|
||||
|
||||
_MEMBER_KIND_LABELS = {
|
||||
"regular": "普通绩效",
|
||||
"manager_personal": "经理个人户",
|
||||
"adjustment": "仅调整单",
|
||||
}
|
||||
|
||||
# 各块列数不同,统一按列号取最大宽度(签字稿打印时不挤在一起)
|
||||
_DEPARTMENT_WIDTHS = (30, 16, 22, 13, 13, 15, 18, 14, 14, 13)
|
||||
|
||||
|
||||
def _write_department_document(
|
||||
sheet,
|
||||
data: dict,
|
||||
*,
|
||||
company_name: str,
|
||||
title: str,
|
||||
filters: str,
|
||||
template_version: str,
|
||||
operator_name: str,
|
||||
) -> None:
|
||||
"""部门汇总签字稿:团队分成 → 全公司 5% → 对账 → 成员 → 未分配 → 签字栏。
|
||||
|
||||
全公司 5% 每位有月结聚合行的经理各计一次,合计在第二块展示;
|
||||
团队块的调整净额仅供成员汇总核对。应发合计由后端 `reconciliation`
|
||||
按聚合行口径算好(含分列舍入差),本函数只渲染。
|
||||
"""
|
||||
params = data.get("params") or {}
|
||||
team_text = params.get("team_text", "")
|
||||
company_text = params.get("company_text", "")
|
||||
teams = data.get("teams") or []
|
||||
unassigned = data.get("unassigned") or []
|
||||
company = data.get("company") or {}
|
||||
reconciliation = data.get("reconciliation") or {}
|
||||
company_share_count = int(company.get("company_share_count") or 0)
|
||||
|
||||
_write_document_header(
|
||||
sheet, len(_DEPARTMENT_WIDTHS), company_name, title, filters, template_version, operator_name
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
"一、团队分成汇总",
|
||||
(
|
||||
_Column("seq", "序号", "int", 6),
|
||||
_Column("manager_name", "经理团队", "text", 16),
|
||||
_Column("manager_status_text", "状态", "text", 8),
|
||||
_Column("member_count", "团队人数", "int", 9),
|
||||
_Column("team_base", "团队基数", "amount", 13),
|
||||
_Column("team_share_amount", f"团队分成({team_text}%)", "amount", 15),
|
||||
_Column("adjust_net", "调整净额(仅核对)", "amount", 16),
|
||||
_Column("team_subtotal", "团队应发小计(含聚合分成)", "amount", 18),
|
||||
_Column("frozen_amount", "月结冻结额", "amount", 14),
|
||||
),
|
||||
[
|
||||
{
|
||||
"seq": index,
|
||||
"manager_name": team["manager_name"],
|
||||
"manager_status_text": "在职" if team["manager_status"] == 1 else "离职",
|
||||
"member_count": team["member_count"],
|
||||
"team_base": team["team_base"],
|
||||
"team_share_amount": team["team_share_amount"],
|
||||
"adjust_net": team["adjust_net"],
|
||||
"team_subtotal": team["team_subtotal"],
|
||||
"frozen_amount": team["frozen_amount"],
|
||||
}
|
||||
for index, team in enumerate(teams, start=1)
|
||||
],
|
||||
{
|
||||
"manager_name": "合计",
|
||||
"member_count": sum(team["member_count"] for team in teams),
|
||||
"team_base": _sum_amount(team["team_base"] for team in teams),
|
||||
"team_share_amount": _sum_amount(
|
||||
team["team_share_amount"] for team in teams
|
||||
),
|
||||
"team_subtotal": _sum_amount(team["team_subtotal"] for team in teams),
|
||||
# 未生成月结时全部为 None,合计也留空(不能显示 0.00 误导)
|
||||
"frozen_amount": (
|
||||
_sum_amount(
|
||||
team["frozen_amount"]
|
||||
for team in teams
|
||||
if team["frozen_amount"] is not None
|
||||
)
|
||||
if any(team["frozen_amount"] is not None for team in teams)
|
||||
else None
|
||||
),
|
||||
},
|
||||
"(无团队)",
|
||||
),
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
f"二、全公司分成(每位有聚合行的经理各计一次,共 {company_share_count} 次)",
|
||||
(
|
||||
_Column("company_base", "全公司基数", "amount", 16),
|
||||
_Column("company_share_pct", "分成比例", "text", 12),
|
||||
_Column("company_share_amount", "单经理分成", "amount", 13),
|
||||
_Column("company_share_count", "计入次数", "int", 10),
|
||||
_Column("company_share_total", "分成合计", "amount", 14),
|
||||
),
|
||||
[
|
||||
{
|
||||
"company_base": company.get("company_base"),
|
||||
"company_share_pct": f"{company_text}%",
|
||||
"company_share_amount": company.get("company_share_amount"),
|
||||
"company_share_count": company_share_count,
|
||||
"company_share_total": company.get("company_share_total"),
|
||||
}
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block(
|
||||
"三、对账汇总",
|
||||
(
|
||||
_Column("item", "项目", "text", 30),
|
||||
_Column("amount", "金额", "amount", 16),
|
||||
_Column("note", "说明", "text", 40),
|
||||
),
|
||||
[
|
||||
{
|
||||
"item": "团队成员应付合计",
|
||||
"amount": reconciliation.get("team_member_payable_total"),
|
||||
},
|
||||
{
|
||||
"item": "未分配人员应付合计",
|
||||
"amount": reconciliation.get("unassigned_payable_total"),
|
||||
},
|
||||
{"item": "团队分成合计(分列)", "amount": reconciliation.get("team_share_total")},
|
||||
{
|
||||
"item": f"全公司分成合计({company_share_count} 次,分列)",
|
||||
"amount": reconciliation.get("company_share_total"),
|
||||
"note": "每位有聚合行的经理各计一次",
|
||||
},
|
||||
{
|
||||
"item": "聚合行舍入差",
|
||||
"amount": reconciliation.get("aggregate_rounding_diff"),
|
||||
"note": "分列舍入与聚合行一次舍入的差",
|
||||
},
|
||||
{
|
||||
"item": "调整净额合计(明细层)",
|
||||
"amount": reconciliation.get("adjust_net_total"),
|
||||
},
|
||||
{
|
||||
"item": "应发合计",
|
||||
"amount": reconciliation.get("payable_total"),
|
||||
"note": "上列各项之和",
|
||||
},
|
||||
{"item": "团队基数合计", "amount": reconciliation.get("team_base_total")},
|
||||
{
|
||||
"item": "未分配绩效合计",
|
||||
"amount": reconciliation.get("unassigned_performance_total"),
|
||||
},
|
||||
{
|
||||
"item": "其他未展示基数",
|
||||
"amount": reconciliation.get("other_team_base"),
|
||||
"note": "筛选时含其他团队与全公司未分配",
|
||||
},
|
||||
{"item": "全公司基数", "amount": reconciliation.get("company_base")},
|
||||
],
|
||||
),
|
||||
)
|
||||
|
||||
member_columns = (
|
||||
_Column("manager_name", "经理团队", "text", 15),
|
||||
_Column("personnel_name", "成员", "text", 12),
|
||||
_Column("role_code", "岗位", "text", 10),
|
||||
_Column("member_kind_text", "来源", "text", 13),
|
||||
_Column("performance_sum", "绩效金额", "amount", 13),
|
||||
_Column("payable_sum", "应付绩效", "amount", 13),
|
||||
_Column("adjust_extra", "调整补发", "amount", 12),
|
||||
_Column("adjust_deduct", "调整扣减", "amount", 12),
|
||||
_Column("adjust_net", "调整净额", "amount", 12),
|
||||
_Column("net_total", "净合计", "amount", 13),
|
||||
)
|
||||
member_rows = [
|
||||
{
|
||||
"manager_name": team["manager_name"],
|
||||
"personnel_name": member["personnel_name"],
|
||||
"role_code": member["role_code"],
|
||||
"member_kind_text": _MEMBER_KIND_LABELS.get(
|
||||
member["member_kind"], member["member_kind"]
|
||||
),
|
||||
"performance_sum": member["performance_sum"],
|
||||
"payable_sum": member["payable_sum"],
|
||||
"adjust_extra": member["adjust_extra"],
|
||||
"adjust_deduct": member["adjust_deduct"],
|
||||
"adjust_net": member["adjust_net"],
|
||||
"net_total": member["net_total"],
|
||||
}
|
||||
for team in teams
|
||||
for member in team["members"]
|
||||
]
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block("四、团队成员明细", member_columns, member_rows, empty_text="(无成员)"),
|
||||
sum_amounts=True,
|
||||
)
|
||||
|
||||
unassigned_columns = (
|
||||
_Column("personnel_name", "人员", "text", 14),
|
||||
_Column("role_code", "岗位", "text", 10),
|
||||
_Column("unassigned_reason", "未纳入原因", "text", 24),
|
||||
_Column("performance_sum", "绩效金额", "amount", 13),
|
||||
_Column("payable_sum", "应付绩效", "amount", 13),
|
||||
_Column("adjust_extra", "调整补发", "amount", 12),
|
||||
_Column("adjust_deduct", "调整扣减", "amount", 12),
|
||||
_Column("adjust_net", "调整净额", "amount", 12),
|
||||
_Column("net_total", "净合计", "amount", 13),
|
||||
)
|
||||
unassigned_rows = [
|
||||
{
|
||||
"personnel_name": row["personnel_name"],
|
||||
"role_code": row["role_code"],
|
||||
"unassigned_reason": row.get("unassigned_reason") or "",
|
||||
"performance_sum": row["performance_sum"],
|
||||
"payable_sum": row["payable_sum"],
|
||||
"adjust_extra": row["adjust_extra"],
|
||||
"adjust_deduct": row["adjust_deduct"],
|
||||
"adjust_net": row["adjust_net"],
|
||||
"net_total": row["net_total"],
|
||||
}
|
||||
for row in unassigned
|
||||
]
|
||||
_write_block(
|
||||
sheet,
|
||||
_Block("五、未分配人员明细", unassigned_columns, unassigned_rows),
|
||||
sum_amounts=True,
|
||||
)
|
||||
|
||||
_write_signature(sheet, len(_DEPARTMENT_WIDTHS))
|
||||
for index, width in enumerate(_DEPARTMENT_WIDTHS, start=1):
|
||||
sheet.column_dimensions[get_column_letter(index)].width = width
|
||||
|
||||
|
||||
def _write_block(sheet, block: _Block, *, sum_amounts: bool = False) -> None:
|
||||
"""写一个区块:标题、表头、数据/空提示,以及自定义或自动合计。"""
|
||||
sheet.append([block.title])
|
||||
title_row = sheet.max_row
|
||||
_merge_row(sheet, len(block.columns))
|
||||
sheet.cell(row=title_row, column=1).font = _HEADER_FONT
|
||||
|
||||
sheet.append([column.title for column in block.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")
|
||||
|
||||
if block.rows:
|
||||
for row in block.rows:
|
||||
_write_row(sheet, block.columns, row)
|
||||
else:
|
||||
sheet.append([block.empty_text])
|
||||
_merge_row(sheet, len(block.columns))
|
||||
|
||||
if block.total is not None:
|
||||
_write_custom_total(sheet, block.columns, block.total)
|
||||
elif sum_amounts:
|
||||
_write_total(sheet, block.columns, block.rows)
|
||||
sheet.append([])
|
||||
|
||||
|
||||
def _write_custom_total(sheet, columns: tuple[_Column, ...], total: dict) -> None:
|
||||
"""写后端预计算的合计行(不自动求和,避免重复计入共享基数)。"""
|
||||
values = []
|
||||
for column in columns:
|
||||
raw = total.get(column.key)
|
||||
values.append(_cell_value(column, raw) if raw is not None else None)
|
||||
sheet.append(values)
|
||||
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 _sum_amount(values) -> Decimal:
|
||||
"""金额列求和后两位收口(None 跳过)。"""
|
||||
return sum((value for value in values if value is not None), Decimal("0")).quantize(
|
||||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||||
)
|
||||
|
||||
@ -24,11 +24,14 @@ 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 (
|
||||
BUSINESS_STATUS_LABELS,
|
||||
CUSTOMER_TYPE_LABELS,
|
||||
PRODUCT_CATEGORY_LABELS,
|
||||
REPORT_EXPORT_MAX_ROWS,
|
||||
REPORT_TEMPLATE_VERSION,
|
||||
DayBucket,
|
||||
)
|
||||
from app.models import BusinessRole, PerformanceLedger, Personnel, User
|
||||
from app.models import BusinessRole, Customer, 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
|
||||
@ -124,12 +127,19 @@ def _q2(value: Decimal | None) -> Decimal | None:
|
||||
|
||||
|
||||
def department_summary(session, query) -> dict:
|
||||
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。"""
|
||||
"""按经理团队/月份汇总绩效工资(团队基数与月结聚合行同源)。
|
||||
|
||||
人员归属按「行类型」而不是「人」划分:普通台账行跟随 ``manager_id``,
|
||||
经理个人户行按开关归经理本人——与 ``collect_team_buckets`` 完全同口径。
|
||||
调整单是人的属性,优先跟随其普通行归属;只有经理个人户的经理跟随
|
||||
本人团队。每个人/每类金额在团队与未分配之间只出现一次。
|
||||
"""
|
||||
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)
|
||||
include_mp = share.include_manager_personal
|
||||
buckets = manager_share.collect_team_buckets(session, rows, include_mp)
|
||||
company_base = manager_share.company_base_of(rows, include_mp)
|
||||
|
||||
aggregates = {
|
||||
row.personnel_id: row
|
||||
@ -137,70 +147,235 @@ def department_summary(session, query) -> dict:
|
||||
}
|
||||
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)
|
||||
# 先构建全部团队再按筛选展示:other_*(隐藏团队金额)需要完整集合,
|
||||
# 只按 query.manager_id 枚举会让筛选态的 other_* 恒为 0
|
||||
manager_ids = _manager_ids(session, buckets, None)
|
||||
if query.manager_id is not None and query.manager_id not in manager_ids:
|
||||
manager_ids = sorted(set(manager_ids) | {query.manager_id})
|
||||
# 岗位判定范围必须覆盖「无成员的经理」与调整单人员——只按 member_map 取
|
||||
# 会让零行团队被跳过,也会让其他经理的下属被误判为无经理
|
||||
manager_of, manager_role, person_info = _team_relations(
|
||||
session, member_map, manager_ids, adjustments
|
||||
)
|
||||
# 只有调整单、本月无台账行的人:补成零值成员后参与归属
|
||||
_merge_adjustment_only_members(member_map, adjustments, person_info)
|
||||
|
||||
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,
|
||||
}
|
||||
)
|
||||
context = {
|
||||
"aggregates": aggregates,
|
||||
"buckets": buckets,
|
||||
"member_map": member_map,
|
||||
"manager_of": manager_of,
|
||||
"manager_role": manager_role,
|
||||
"adjustments": adjustments,
|
||||
"include_mp": include_mp,
|
||||
"share": share,
|
||||
"company_base": company_base,
|
||||
}
|
||||
teams_all = _build_teams(session, manager_ids, context)
|
||||
if query.manager_id is None:
|
||||
# 离职经理本月无台账、但其下属只有调整单时,团队仍必须展示,
|
||||
# 否则调整金额会从团队与未分配同时消失
|
||||
extra_ids = {
|
||||
target
|
||||
for pid in adjustments
|
||||
if (
|
||||
target := _adjustment_target(
|
||||
pid, member_map, manager_of, manager_role, include_mp
|
||||
)
|
||||
)
|
||||
is not None
|
||||
} - set(manager_ids)
|
||||
if extra_ids:
|
||||
teams_all.extend(_build_teams(session, sorted(extra_ids), context))
|
||||
|
||||
unassigned = _unassigned_rows(member_map, manager_of, manager_role, adjustments)
|
||||
teams = [
|
||||
team
|
||||
for team in teams_all
|
||||
if query.manager_id is None or team["manager_id"] == query.manager_id
|
||||
]
|
||||
unassigned_all = _unassigned_rows(
|
||||
member_map, manager_of, manager_role, adjustments, include_mp
|
||||
)
|
||||
# 「仅看某经理团队」时未分配桶不随展示:全公司无经理人员与该团队无关
|
||||
unassigned = [] if query.manager_id is not None else unassigned_all
|
||||
company_share_amount = _company_share(company_base, share.company_pct)
|
||||
company_share_count = sum(1 for team in teams if team["has_aggregate"])
|
||||
reconciliation = _reconciliation(teams, teams_all, unassigned, company_base)
|
||||
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,
|
||||
"manager_personal_included": 1 if include_mp 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"))),
|
||||
"team_base_total": reconciliation["team_base_total"],
|
||||
"company_base": _q2(company_base),
|
||||
"company_share_pct": share.company_text,
|
||||
"company_share_amount": _q2(_company_share(company_base, share.company_pct)),
|
||||
# 单经理名义值;结算总额见 company_share_total(每位有聚合行的经理各计一次)
|
||||
"company_share_amount": _q2(company_share_amount),
|
||||
"company_share_count": company_share_count,
|
||||
"company_share_total": _q2(company_share_amount * company_share_count),
|
||||
},
|
||||
"reconciliation": reconciliation,
|
||||
"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"))),
|
||||
"performance_total": reconciliation["team_base_total"],
|
||||
"payable_total": reconciliation["payable_total"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _build_teams(session, manager_ids: list[int], context: dict) -> list[dict]:
|
||||
"""按经理枚举生成团队行(含零成员团队)。"""
|
||||
teams = []
|
||||
for manager_id in manager_ids:
|
||||
team = _build_team(session, manager_id, context)
|
||||
if team is not None:
|
||||
teams.append(team)
|
||||
return teams
|
||||
|
||||
|
||||
def _build_team(session, manager_id: int, context: dict) -> dict | None:
|
||||
"""单个经理团队行;非营销经理的上级不展示(与月结聚合同口径)。"""
|
||||
manager = session.get(Personnel, manager_id)
|
||||
if manager is None or context["manager_role"].get(manager_id) != "sales_manager":
|
||||
return None
|
||||
aggregate = context["aggregates"].get(manager_id)
|
||||
member_rows = _member_rows(
|
||||
manager_id,
|
||||
context["member_map"],
|
||||
context["manager_of"],
|
||||
context["manager_role"],
|
||||
context["adjustments"],
|
||||
context["include_mp"],
|
||||
)
|
||||
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(context["buckets"].get(manager_id, {}).get("performance", Decimal("0")))
|
||||
# 月结只为有台账 bucket 的营销经理生成聚合行;零成员团队不发 5%
|
||||
has_aggregate = manager_id in context["buckets"] or aggregate is not None
|
||||
team_share = _team_share(team_base, context["share"].team_pct)
|
||||
# 聚合额必须与月结「一次舍入」口径一致:优先取冻结行;未生成月结时
|
||||
# 才用 12.5%+5% 合并公式预览。分列各自 q2 后相加会差 0.01~0.02。
|
||||
if aggregate is not None:
|
||||
aggregate_amount = _dec(aggregate.performance_amount or Decimal("0"))
|
||||
elif manager_id in context["buckets"]:
|
||||
aggregate_amount = _q2(
|
||||
manager_team_share(
|
||||
team_base,
|
||||
context["company_base"],
|
||||
team_pct=context["share"].team_pct,
|
||||
company_pct=context["share"].company_pct,
|
||||
)
|
||||
)
|
||||
else:
|
||||
aggregate_amount = Decimal("0")
|
||||
company_share = (
|
||||
_company_share(context["company_base"], context["share"].company_pct)
|
||||
if has_aggregate
|
||||
else Decimal("0")
|
||||
)
|
||||
team_subtotal = _q2(aggregate_amount + team_extra - team_deduct)
|
||||
return {
|
||||
"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),
|
||||
# 单经理 5% 名义值;无聚合行时为 0(不会发放)
|
||||
"company_share_amount": _q2(company_share),
|
||||
"has_aggregate": has_aggregate,
|
||||
# 与月结聚合行同口径(12.5% + 5% 一次舍入);无聚合行时为 0
|
||||
"aggregate_amount": _q2(aggregate_amount),
|
||||
# 团队应发小计 = 聚合行金额 + 调整净额;不再另加一次 5%
|
||||
"team_subtotal": team_subtotal,
|
||||
"company_base_ratio": _ratio(team_base, context["company_base"]),
|
||||
"adjust_extra": _q2(team_extra),
|
||||
"adjust_deduct": _q2(team_deduct),
|
||||
"adjust_net": _q2(team_extra - team_deduct),
|
||||
"net_total": team_subtotal,
|
||||
"frozen_amount": aggregate.performance_amount if aggregate else None,
|
||||
"aggregate_ledger_id": aggregate.id if aggregate else None,
|
||||
"members": member_rows,
|
||||
}
|
||||
|
||||
|
||||
def _reconciliation(
|
||||
teams: list[dict],
|
||||
teams_all: list[dict],
|
||||
unassigned: list[dict],
|
||||
company_base: Decimal,
|
||||
) -> dict:
|
||||
"""展示口径的对账块。
|
||||
|
||||
应发 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额。
|
||||
聚合行金额(``aggregate_amount``)含 12.5%+5%,且与月结「一次舍入」
|
||||
完全一致;``team_share_total``/``company_share_total`` 只作分列展示,
|
||||
两者之和与聚合额的差记入 ``aggregate_rounding_diff``(分列各自 q2
|
||||
会产生 0.01~0.02 的尾差,直接相加会与月结总额不符)。
|
||||
|
||||
筛选态下 ``other_*`` 记录被隐藏团队的金额;它们不进入应发。
|
||||
"""
|
||||
displayed_ids = {team["manager_id"] for team in teams}
|
||||
other_teams = [team for team in teams_all if team["manager_id"] not in displayed_ids]
|
||||
|
||||
team_member_payable = sum(
|
||||
(m["payable_sum"] for team in teams for m in team["members"]), Decimal("0")
|
||||
)
|
||||
unassigned_payable = sum((row["payable_sum"] for row in unassigned), Decimal("0"))
|
||||
team_share_total = sum((team["team_share_amount"] for team in teams), Decimal("0"))
|
||||
company_share_total = sum((team["company_share_amount"] for team in teams), Decimal("0"))
|
||||
aggregate_payable_total = sum((team["aggregate_amount"] for team in teams), Decimal("0"))
|
||||
aggregate_rounding_diff = aggregate_payable_total - team_share_total - company_share_total
|
||||
adjust_net_total = sum(
|
||||
(m["adjust_net"] for team in teams for m in team["members"]), Decimal("0")
|
||||
) + sum((row["adjust_net"] for row in unassigned), Decimal("0"))
|
||||
team_base_total = sum((team["team_base"] for team in teams), Decimal("0"))
|
||||
unassigned_performance = sum(
|
||||
(row["performance_sum"] for row in unassigned), Decimal("0")
|
||||
)
|
||||
payable_total = (
|
||||
team_member_payable + unassigned_payable + aggregate_payable_total + adjust_net_total
|
||||
)
|
||||
return {
|
||||
"team_member_payable_total": _q2(team_member_payable),
|
||||
"unassigned_payable_total": _q2(unassigned_payable),
|
||||
"team_share_total": _q2(team_share_total),
|
||||
# 各位有聚合行的经理 5% 之和(单份 × 计入次数)
|
||||
"company_share_total": _q2(company_share_total),
|
||||
# 与月结聚合行同口径的应发合计(12.5% + 5% 一次舍入)
|
||||
"aggregate_payable_total": _q2(aggregate_payable_total),
|
||||
# 分列展示与聚合额之间的舍入差;导出签字稿单列一行
|
||||
"aggregate_rounding_diff": _q2(aggregate_rounding_diff),
|
||||
"adjust_net_total": _q2(adjust_net_total),
|
||||
"payable_total": _q2(payable_total),
|
||||
"team_base_total": _q2(team_base_total),
|
||||
"unassigned_performance_total": _q2(unassigned_performance),
|
||||
# 筛选时包含其他团队基数与全公司未分配;未筛选时应为 0
|
||||
"other_team_base": _q2(company_base - team_base_total - unassigned_performance),
|
||||
"other_team_member_payable_total": _q2(
|
||||
sum((m["payable_sum"] for team in other_teams for m in team["members"]), Decimal("0"))
|
||||
),
|
||||
"other_team_share_total": _q2(
|
||||
sum((team["team_share_amount"] for team in other_teams), Decimal("0"))
|
||||
),
|
||||
"other_team_company_share_total": _q2(
|
||||
sum((team["company_share_amount"] for team in other_teams), Decimal("0"))
|
||||
),
|
||||
"other_team_aggregate_total": _q2(
|
||||
sum((team["aggregate_amount"] for team in other_teams), Decimal("0"))
|
||||
),
|
||||
"other_adjust_net_total": _q2(
|
||||
sum((team["adjust_net"] for team in other_teams), Decimal("0"))
|
||||
),
|
||||
"company_base": _q2(company_base),
|
||||
}
|
||||
|
||||
|
||||
def _team_share(team_base: Decimal, team_pct: Fraction) -> Decimal:
|
||||
"""12.5% 团队分成:全公司部分传 0,仅取 team_pct 项。"""
|
||||
return manager_team_share(
|
||||
@ -223,11 +398,19 @@ def _ratio(part: Decimal, whole: Decimal) -> Decimal:
|
||||
|
||||
|
||||
def _adjustments_by_person(orders) -> dict[int, dict]:
|
||||
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。"""
|
||||
"""生效调整单按人汇总(补发为正、扣减为负;金额本身恒正)。
|
||||
|
||||
不要求该人本月有台账行——只有调整单的人也必须进入报表(计划 §一.3)。
|
||||
"""
|
||||
by_person: dict[int, dict] = {}
|
||||
for order in orders:
|
||||
acc = by_person.setdefault(
|
||||
order.personnel_id, {"extra": Decimal("0"), "deduct": Decimal("0")}
|
||||
order.personnel_id,
|
||||
{
|
||||
"extra": Decimal("0"),
|
||||
"deduct": Decimal("0"),
|
||||
"personnel_name": order.personnel_name,
|
||||
},
|
||||
)
|
||||
if order.direction == ADJUSTMENT_DIRECTION_EXTRA:
|
||||
acc["extra"] += order.amount
|
||||
@ -237,7 +420,11 @@ def _adjustments_by_person(orders) -> dict[int, dict]:
|
||||
|
||||
|
||||
def _member_map(rows) -> dict[int, dict]:
|
||||
"""台账行按人求和(成员明细的唯一数据源),经理个人户金额单独记账。"""
|
||||
"""台账行按人求和(成员明细的唯一数据源)。
|
||||
|
||||
普通行与经理个人户行分开记账:团队基数只含后者,成员行也只能取
|
||||
后者,否则经理本人的普通业绩会同时出现在成员行与未分配中。
|
||||
"""
|
||||
members: dict[int, dict] = {}
|
||||
for row in rows:
|
||||
acc = members.setdefault(
|
||||
@ -248,9 +435,13 @@ def _member_map(rows) -> dict[int, dict]:
|
||||
"is_probation": row.is_probation,
|
||||
"performance": Decimal("0"),
|
||||
"payable": Decimal("0"),
|
||||
"regular_performance": Decimal("0"),
|
||||
"regular_payable": Decimal("0"),
|
||||
"manager_personal_performance": Decimal("0"),
|
||||
"manager_personal_payable": Decimal("0"),
|
||||
"ledger_ids": [],
|
||||
"regular_ledger_ids": [],
|
||||
"manager_personal_ledger_ids": [],
|
||||
"has_regular_rows": False,
|
||||
"has_manager_personal": False,
|
||||
},
|
||||
)
|
||||
@ -258,34 +449,90 @@ def _member_map(rows) -> dict[int, dict]:
|
||||
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
|
||||
acc["manager_personal_ledger_ids"].append(row.id)
|
||||
else:
|
||||
acc["has_regular_rows"] = True
|
||||
acc["regular_performance"] += performance
|
||||
acc["regular_payable"] += payable
|
||||
acc["regular_ledger_ids"].append(row.id)
|
||||
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)。
|
||||
session,
|
||||
member_map: dict[int, dict],
|
||||
manager_ids: list[int],
|
||||
adjustments: dict[int, dict],
|
||||
) -> tuple[dict[int, int | None], dict[int, str], dict[int, dict]]:
|
||||
"""人员 → 直属经理、岗位编码与展示信息(批量查询,避免 N+1)。
|
||||
|
||||
查询范围 = 成员 ∪ 经理枚举:无成员的经理也要有岗位判定,否则「零行团队」
|
||||
会被岗位过滤静默跳过(FR-030 要求无团队成员出 0 行)。
|
||||
查询范围 = 成员 ∪ 经理枚举 ∪ 调整单人员;随后再补查这些人引用的
|
||||
上级。缺少上级的岗位信息时,其他经理的下属会被误判为「无经理」,
|
||||
零成员团队也会被岗位过滤静默跳过(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}
|
||||
seed = set(member_map) | set(manager_ids) | set(adjustments)
|
||||
if not seed:
|
||||
return {}, {}, {}
|
||||
people = list(session.execute(select(Personnel).where(Personnel.id.in_(seed))).scalars())
|
||||
referenced = {p.manager_id for p in people if p.manager_id is not None}
|
||||
missing = referenced - seed
|
||||
if missing:
|
||||
people.extend(
|
||||
session.execute(select(Personnel).where(Personnel.id.in_(missing))).scalars()
|
||||
)
|
||||
role_by_id = {
|
||||
r.id: r.role_code for r in session.execute(select(BusinessRole)).scalars()
|
||||
}
|
||||
manager_of = {p.id: p.manager_id for p in people}
|
||||
manager_role = {p.id: role_by_id.get(p.business_role_id, "") for p in people}
|
||||
return manager_of, manager_role
|
||||
person_info = {
|
||||
p.id: {
|
||||
"personnel_name": p.personnel_name,
|
||||
"role_code": role_by_id.get(p.business_role_id, ""),
|
||||
"is_probation": p.is_probation,
|
||||
}
|
||||
for p in people
|
||||
}
|
||||
return manager_of, manager_role, person_info
|
||||
|
||||
|
||||
def _merge_adjustment_only_members(
|
||||
member_map: dict[int, dict], adjustments: dict[int, dict], person_info: dict[int, dict]
|
||||
) -> None:
|
||||
"""把「只有调整单、本月无台账行」的人补成零值成员。
|
||||
|
||||
补进 member_map 后,``_member_rows`` / ``_unassigned_rows`` 的归属
|
||||
逻辑无需再为调整单单独开一条遍历分支;绩效为 0 也保证团队基数不变。
|
||||
"""
|
||||
for personnel_id, adjustment in adjustments.items():
|
||||
if personnel_id in member_map:
|
||||
continue
|
||||
info = person_info.get(personnel_id) or {
|
||||
"personnel_name": adjustment["personnel_name"],
|
||||
"role_code": "",
|
||||
"is_probation": 0,
|
||||
}
|
||||
member_map[personnel_id] = {
|
||||
"personnel_name": info["personnel_name"],
|
||||
"role_code": info["role_code"],
|
||||
"is_probation": info["is_probation"],
|
||||
"performance": Decimal("0"),
|
||||
"payable": Decimal("0"),
|
||||
"regular_performance": Decimal("0"),
|
||||
"regular_payable": Decimal("0"),
|
||||
"manager_personal_performance": Decimal("0"),
|
||||
"manager_personal_payable": Decimal("0"),
|
||||
"regular_ledger_ids": [],
|
||||
"manager_personal_ledger_ids": [],
|
||||
"has_regular_rows": False,
|
||||
"has_manager_personal": False,
|
||||
}
|
||||
|
||||
|
||||
def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
|
||||
@ -304,37 +551,143 @@ def _manager_ids(session, buckets: dict, only: int | None) -> list[int]:
|
||||
return sorted(found)
|
||||
|
||||
|
||||
def _regular_team_of(
|
||||
pid: int, manager_of: dict[int, int | None], manager_role: dict[int, str]
|
||||
) -> int | None:
|
||||
"""普通台账行的团队归属:直属上级是营销经理时归其团队。"""
|
||||
manager_id = manager_of.get(pid)
|
||||
if manager_id is not None and manager_role.get(manager_id) == "sales_manager":
|
||||
return manager_id
|
||||
return None
|
||||
|
||||
|
||||
def _adjustment_target(
|
||||
pid: int,
|
||||
member_map: dict[int, dict],
|
||||
manager_of: dict[int, int | None],
|
||||
manager_role: dict[int, str],
|
||||
include_manager_personal: bool,
|
||||
) -> int | None:
|
||||
"""调整单归属:优先跟随普通行;只有经理个人户的经理跟随本人团队。
|
||||
|
||||
调整单没有行类型,只能按人的实时归属落位。每人只允许一行携带调整,
|
||||
因此这里返回唯一的团队键(None = 未分配)。
|
||||
"""
|
||||
member = member_map.get(pid)
|
||||
if member is not None and member["has_regular_rows"]:
|
||||
return _regular_team_of(pid, manager_of, manager_role)
|
||||
if (
|
||||
member is not None
|
||||
and include_manager_personal
|
||||
and member["has_manager_personal"]
|
||||
and manager_role.get(pid) == "sales_manager"
|
||||
):
|
||||
return pid
|
||||
return _regular_team_of(pid, manager_of, manager_role)
|
||||
|
||||
|
||||
def _member_rows(
|
||||
manager_id: int,
|
||||
member_map: dict[int, dict],
|
||||
manager_of: dict[int, int | None],
|
||||
manager_role: dict[int, str],
|
||||
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]
|
||||
"""团队内成员行:普通行按 manager_id 归属;经理个人户按开关归属本人。
|
||||
|
||||
经理本人只以「经理个人户金额」出现在自己团队,普通行业绩留在上级
|
||||
团队或未分配——同一笔业绩不会在团队与未分配中重复展示。
|
||||
"""
|
||||
rows = []
|
||||
for pid, member in member_map.items():
|
||||
target = _adjustment_target(
|
||||
pid, member_map, manager_of, manager_role, include_manager_personal
|
||||
)
|
||||
regular_team = _regular_team_of(pid, manager_of, manager_role)
|
||||
if pid != manager_id and member["has_regular_rows"] and regular_team == manager_id:
|
||||
rows.append(
|
||||
_member_row(
|
||||
pid,
|
||||
member,
|
||||
adjustments,
|
||||
"regular",
|
||||
performance=member["regular_performance"],
|
||||
payable=member["regular_payable"],
|
||||
ledger_ids=member["regular_ledger_ids"],
|
||||
include_adjust=target == manager_id,
|
||||
)
|
||||
)
|
||||
personal_in_team = (
|
||||
pid == manager_id
|
||||
and include_manager_personal
|
||||
and member["has_manager_personal"]
|
||||
)
|
||||
if personal_in_team:
|
||||
rows.append(
|
||||
_member_row(
|
||||
pid,
|
||||
member,
|
||||
adjustments,
|
||||
"manager_personal",
|
||||
performance=member["manager_personal_performance"],
|
||||
payable=member["manager_personal_payable"],
|
||||
ledger_ids=member["manager_personal_ledger_ids"],
|
||||
include_adjust=target == manager_id,
|
||||
)
|
||||
)
|
||||
carried = (
|
||||
member["has_regular_rows"] and regular_team == manager_id and target == manager_id
|
||||
) or (personal_in_team and target == manager_id)
|
||||
# 调整单没有被普通行/个人户行承载时单独出一行(例如只有个人户、
|
||||
# 但上级是营销经理的人员:调整随实时 manager_id 归上级团队)
|
||||
if pid in adjustments and target == manager_id and not carried:
|
||||
rows.append(
|
||||
_member_row(
|
||||
pid,
|
||||
member,
|
||||
adjustments,
|
||||
"adjustment",
|
||||
performance=Decimal("0"),
|
||||
payable=Decimal("0"),
|
||||
ledger_ids=[],
|
||||
include_adjust=True,
|
||||
)
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
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"]
|
||||
def _member_row(
|
||||
pid: int,
|
||||
member: dict,
|
||||
adjustments: dict[int, dict],
|
||||
member_kind: str,
|
||||
*,
|
||||
performance: Decimal,
|
||||
payable: Decimal,
|
||||
ledger_ids: list[int],
|
||||
include_adjust: bool,
|
||||
) -> dict:
|
||||
"""单条成员行(按行类型取金额,调整净额最多计入一次)。"""
|
||||
adj = (
|
||||
adjustments.get(pid, {"extra": Decimal("0"), "deduct": Decimal("0")})
|
||||
if include_adjust
|
||||
else {"extra": Decimal("0"), "deduct": Decimal("0")}
|
||||
)
|
||||
net = 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"]),
|
||||
"member_kind": member_kind,
|
||||
"performance_sum": _q2(performance),
|
||||
"payable_sum": _q2(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"],
|
||||
"ledger_ids": list(ledger_ids),
|
||||
}
|
||||
|
||||
|
||||
@ -343,24 +696,68 @@ def _unassigned_rows(
|
||||
manager_of: dict[int, int | None],
|
||||
manager_role: dict[int, str],
|
||||
adjustments: dict[int, dict],
|
||||
include_manager_personal: bool,
|
||||
) -> 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)
|
||||
target = _adjustment_target(
|
||||
pid, member_map, manager_of, manager_role, include_manager_personal
|
||||
)
|
||||
regular_team = _regular_team_of(pid, manager_of, manager_role)
|
||||
regular_unassigned = member["has_regular_rows"] and regular_team is None
|
||||
personal_unassigned = (
|
||||
include_manager_personal
|
||||
and member["has_manager_personal"]
|
||||
and manager_role.get(pid) != "sales_manager"
|
||||
)
|
||||
if regular_unassigned:
|
||||
row = _member_row(
|
||||
pid,
|
||||
member,
|
||||
adjustments,
|
||||
"regular",
|
||||
performance=member["regular_performance"],
|
||||
payable=member["regular_payable"],
|
||||
ledger_ids=member["regular_ledger_ids"],
|
||||
include_adjust=target is None,
|
||||
)
|
||||
row["unassigned_reason"] = "无经理或上级非营销经理"
|
||||
rows.append(row)
|
||||
if personal_unassigned:
|
||||
row = _member_row(
|
||||
pid,
|
||||
member,
|
||||
adjustments,
|
||||
"manager_personal",
|
||||
performance=member["manager_personal_performance"],
|
||||
payable=member["manager_personal_payable"],
|
||||
ledger_ids=member["manager_personal_ledger_ids"],
|
||||
include_adjust=target is None and not member["has_regular_rows"],
|
||||
)
|
||||
row["unassigned_reason"] = "经理个人户(本人非营销经理)"
|
||||
rows.append(row)
|
||||
carried = (regular_unassigned and target is None) or (
|
||||
personal_unassigned and target is None and not member["has_regular_rows"]
|
||||
)
|
||||
if pid in adjustments and target is None and not carried:
|
||||
row = _member_row(
|
||||
pid,
|
||||
member,
|
||||
adjustments,
|
||||
"adjustment",
|
||||
performance=Decimal("0"),
|
||||
payable=Decimal("0"),
|
||||
ledger_ids=[],
|
||||
include_adjust=True,
|
||||
)
|
||||
row["unassigned_reason"] = "仅有调整单且无团队归属"
|
||||
rows.append(row)
|
||||
return rows
|
||||
|
||||
|
||||
@ -547,26 +944,41 @@ EXPORT_FILENAMES = {
|
||||
"payment-stats": "payment_stats",
|
||||
}
|
||||
|
||||
# 标题是导出审计摘要的一部分:进入 audit 前就要确定,不能在取数后才拼
|
||||
EXPORT_TITLES = {
|
||||
"personal-detail": "个人绩效工资明细表",
|
||||
"department-summary": "部门绩效工资汇总表",
|
||||
"business-stats": "业务统计表",
|
||||
"payment-stats": "回款情况统计表",
|
||||
}
|
||||
|
||||
|
||||
def export_report(session, code: str, query, *, operator: User, meta: RequestMeta | None = None):
|
||||
"""导出 Excel:上限校验 → 取数 → 构建 → 审计留痕(独立 commit)。
|
||||
"""导出 Excel:取数 → 构建 → 审计留痕(独立 commit)。
|
||||
|
||||
取数与上限校验必须在审计上下文内:超限 400 和取数异常同样要留下
|
||||
失败记录(M4 计划 T12「导出 400/失败审计」)。`AuditRecord` 是 frozen
|
||||
的,摘要只写静态标题,超限行数由异常文案带入 error_message。
|
||||
|
||||
返回 (bytes, 文件名);文件名由 API 层拼日期(ASCII,响应头 latin-1)。
|
||||
"""
|
||||
from app.services import report_export
|
||||
|
||||
rows, context = _export_payload(session, code, query)
|
||||
title = EXPORT_TITLES.get(code)
|
||||
if title is None:
|
||||
raise BadRequestError(f"未知的报表编码:{code}")
|
||||
with audit_service.audit(
|
||||
session,
|
||||
operator=operator,
|
||||
record=AuditRecord(
|
||||
module=MODULE_REPORT,
|
||||
action=ACTION_EXPORT,
|
||||
summary=f"导出{context['title']}({context['row_count']} 行)",
|
||||
summary=f"导出{title}",
|
||||
request_data=query.model_dump(exclude_none=True),
|
||||
),
|
||||
meta=meta,
|
||||
):
|
||||
rows, context = _export_payload(session, code, query)
|
||||
content = report_export.build_workbook(
|
||||
code=code,
|
||||
title=context["title"],
|
||||
@ -574,6 +986,7 @@ def export_report(session, code: str, query, *, operator: User, meta: RequestMet
|
||||
rows=rows,
|
||||
summary=context.get("summary"),
|
||||
buckets=context.get("buckets"),
|
||||
department=context.get("department"),
|
||||
template_version=REPORT_TEMPLATE_VERSION,
|
||||
operator_name=getattr(operator, "username", ""),
|
||||
company_name=context["company_name"],
|
||||
@ -592,9 +1005,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
||||
role_names = _role_names(session)
|
||||
sums = report_repository.sum_personal_detail(session, query)
|
||||
return [_person_row(r, role_names) for r in rows], {
|
||||
"title": "个人绩效工资明细表",
|
||||
"title": EXPORT_TITLES["personal-detail"],
|
||||
"row_count": total,
|
||||
"filters": _filter_text(query),
|
||||
"filters": _filter_text(session, query),
|
||||
"summary": {
|
||||
"performance_total": _q2(_dec(sums["performance_total"])),
|
||||
"payable_total": _q2(_dec(sums["payable_total"])),
|
||||
@ -603,14 +1016,17 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
||||
}
|
||||
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"],
|
||||
row_count = (
|
||||
len(data["teams"])
|
||||
+ sum(len(team["members"]) for team in data["teams"])
|
||||
+ len(data["unassigned"])
|
||||
)
|
||||
_check_export_limit(row_count)
|
||||
return [], {
|
||||
"title": EXPORT_TITLES["department-summary"],
|
||||
"row_count": row_count,
|
||||
"filters": _filter_text(session, query),
|
||||
"department": data,
|
||||
"company_name": _company_name(session),
|
||||
}
|
||||
if code == "business-stats":
|
||||
@ -621,9 +1037,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
||||
)
|
||||
sums = report_repository.sum_business_stats(session, query)
|
||||
return [_business_row(r) for r in rows], {
|
||||
"title": "业务统计表",
|
||||
"title": EXPORT_TITLES["business-stats"],
|
||||
"row_count": total,
|
||||
"filters": _filter_text(query),
|
||||
"filters": _filter_text(session, query),
|
||||
"summary": {
|
||||
"ship_total": _q2(_dec(sums["ship_total"])),
|
||||
"received_total": _q2(_dec(sums["received_total"])),
|
||||
@ -635,9 +1051,9 @@ def _export_payload(session, code: str, query) -> tuple[list[dict], dict]:
|
||||
data = payment_stats(session, query)
|
||||
rows = data["buckets"]
|
||||
return rows, {
|
||||
"title": "回款情况统计表",
|
||||
"title": EXPORT_TITLES["payment-stats"],
|
||||
"row_count": len(rows),
|
||||
"filters": _filter_text(query),
|
||||
"filters": _filter_text(session, query),
|
||||
"summary": data["totals"],
|
||||
"buckets": data,
|
||||
"company_name": _company_name(session),
|
||||
@ -654,43 +1070,79 @@ def _check_export_limit(row_count: int) -> None:
|
||||
)
|
||||
|
||||
|
||||
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(session, query) -> str:
|
||||
"""筛选条件摘要(导出页眉展示;未设置项不展示;ID 转名称)。
|
||||
|
||||
|
||||
def _filter_text(query) -> str:
|
||||
"""筛选条件摘要(导出页眉展示;未设置项不展示)。"""
|
||||
页眉是签字稿追溯筛选范围的唯一凭据:漏掉人员/经理/客户/归属人/
|
||||
状态会让复核者把筛选结果当成全量数据。
|
||||
"""
|
||||
personnel_ids = {
|
||||
value
|
||||
for field in ("personnel_id", "manager_id", "owner_personnel_id")
|
||||
if (value := getattr(query, field, None)) not in (None, "")
|
||||
}
|
||||
personnel_names = _personnel_names(session, personnel_ids)
|
||||
parts = []
|
||||
for field, label in (
|
||||
("settlement_month", "统计月份"),
|
||||
("personnel_id", "人员"),
|
||||
("manager_id", "经理"),
|
||||
("customer_id", "客户"),
|
||||
("owner_personnel_id", "归属人"),
|
||||
("customer_type", "客户类型"),
|
||||
("product_category", "产品类别"),
|
||||
("role_code", "岗位"),
|
||||
("status", "状态"),
|
||||
("keyword", "关键字"),
|
||||
("date_from", "日期起"),
|
||||
("date_to", "日期止"),
|
||||
):
|
||||
value = getattr(query, field, None)
|
||||
if value:
|
||||
if value in (None, ""):
|
||||
continue
|
||||
if field in ("personnel_id", "manager_id", "owner_personnel_id"):
|
||||
name = personnel_names.get(value)
|
||||
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
|
||||
elif field == "customer_id":
|
||||
name = _customer_name(session, value)
|
||||
parts.append(f"{label} {name}(#{value})" if name else f"{label} #{value}")
|
||||
elif field == "customer_type":
|
||||
parts.append(f"{label} {CUSTOMER_TYPE_LABELS.get(value, value)}")
|
||||
elif field == "product_category":
|
||||
parts.append(f"{label} {PRODUCT_CATEGORY_LABELS.get(value, value)}")
|
||||
elif field == "role_code":
|
||||
parts.append(f"{label} {_role_name_by_code(session, value) or value}")
|
||||
elif field == "status":
|
||||
parts.append(f"{label} {BUSINESS_STATUS_LABELS.get(value, value)}")
|
||||
else:
|
||||
parts.append(f"{label} {value}")
|
||||
return " | ".join(parts) or "全部"
|
||||
|
||||
|
||||
def _personnel_names(session, ids: set[int]) -> dict[int, str]:
|
||||
"""批量取人员姓名;查不到时由调用方回退展示 #id。"""
|
||||
if not ids:
|
||||
return {}
|
||||
rows = session.execute(
|
||||
select(Personnel.id, Personnel.personnel_name).where(Personnel.id.in_(ids))
|
||||
).all()
|
||||
return dict(rows)
|
||||
|
||||
|
||||
def _customer_name(session, customer_id: int) -> str | None:
|
||||
"""客户名称(导出页眉 ID→名称)。"""
|
||||
return session.execute(
|
||||
select(Customer.company_name).where(Customer.id == customer_id)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _role_name_by_code(session, role_code: str) -> str | None:
|
||||
"""岗位名称(导出页眉展示中文,而不是内部编码)。"""
|
||||
return session.execute(
|
||||
select(BusinessRole.role_name).where(BusinessRole.role_code == role_code)
|
||||
).scalar_one_or_none()
|
||||
|
||||
|
||||
def _company_name(session) -> str:
|
||||
"""导出页眉公司名(未配置时 system_setting_service 返回默认「衍水集团」)。"""
|
||||
from app.services import system_setting_service
|
||||
|
||||
@ -147,9 +147,18 @@ class TestBuiltinProtection:
|
||||
|
||||
|
||||
class TestPermission:
|
||||
"""权限:普通用户全部 403(当前默认管理员专用)。"""
|
||||
"""权限:管理接口普通用户 403;只读选项登录即可(报表筛选依赖)。"""
|
||||
|
||||
def test_普通用户访问岗位列表_403(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
user = make_user([ROLE_USER])
|
||||
headers = auth_headers(user)
|
||||
assert client.get(_ROLES, headers=headers).status_code == 403
|
||||
|
||||
def test_普通用户可读取岗位选项(self, client: TestClient, make_user, auth_headers) -> None:
|
||||
"""财务/领导做报表岗位筛选时不能因管理员接口 403 而失去筛选能力。"""
|
||||
user = make_user([ROLE_USER])
|
||||
headers = auth_headers(user)
|
||||
resp = client.get("/api/v1/base/business-roles/options", headers=headers)
|
||||
assert resp.status_code == 200, resp.text
|
||||
codes = {row["role_code"] for row in resp.json()["data"]}
|
||||
assert {"salesperson", "sales_manager", "info_officer"} <= codes
|
||||
|
||||
@ -306,3 +306,498 @@ 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"] == "无权访问报表统计"
|
||||
|
||||
|
||||
# ==== 审查回归:归属、调整单、经理个人户、导出审计 ====
|
||||
|
||||
|
||||
def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None:
|
||||
"""立即生效的参数变更(用例不依赖种子默认值,结束显式恢复)。"""
|
||||
resp = client.post(
|
||||
"/api/v1/system/params/changes",
|
||||
json={
|
||||
"change_type": 1,
|
||||
"remark": f"用例设置 {code}={value}",
|
||||
"items": [{"param_code": code, "dimension_value": "", "value": value}],
|
||||
},
|
||||
headers=headers,
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
|
||||
|
||||
def _insert_adjustment(person: dict, month: str, amount: str, direction: int = 1) -> None:
|
||||
"""直插生效调整单(status=2):绕开审批链,聚焦报表归属口径。"""
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import AdjustmentOrder
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
AdjustmentOrder(
|
||||
order_no=f"TZ{uuid.uuid4().hex[:12]}",
|
||||
settlement_month=month,
|
||||
personnel_id=person["id"],
|
||||
personnel_name=person["personnel_name"],
|
||||
direction=direction,
|
||||
amount=Decimal(amount),
|
||||
reason="t_报表用例",
|
||||
status=2,
|
||||
submitter_name="t_admin",
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _insert_manager_personal_ledger(manager: dict, month: str, amount: str) -> None:
|
||||
"""直插一条经理个人户台账行(模拟单笔侧已计提的结果)。"""
|
||||
from datetime import date
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import PerformanceLedger
|
||||
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
session.add(
|
||||
PerformanceLedger(
|
||||
receipt_id=None,
|
||||
calc_version=1,
|
||||
calc_type=1,
|
||||
business_id=0,
|
||||
business_no="t_个人户",
|
||||
personnel_id=manager["id"],
|
||||
personnel_name=manager["personnel_name"],
|
||||
role_code="sales_manager",
|
||||
customer_type="manager_personal",
|
||||
product_category="mixed",
|
||||
receipt_date=date.today(),
|
||||
rate_text="0",
|
||||
performance_amount=Decimal(amount),
|
||||
barter_applied=0,
|
||||
payable_amount=Decimal(amount),
|
||||
settlement_month=month,
|
||||
is_probation=0,
|
||||
)
|
||||
)
|
||||
session.commit()
|
||||
|
||||
|
||||
def _latest_export_log():
|
||||
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:
|
||||
return session.execute(
|
||||
select(OperationLog)
|
||||
.where(OperationLog.module == "报表统计", OperationLog.action == "导出")
|
||||
.order_by(OperationLog.id.desc())
|
||||
).scalars().first()
|
||||
|
||||
|
||||
def test_部门汇总_无台账经理挂上级不报500(admin, client: TestClient):
|
||||
"""经理 A 无台账且上级是经理 B:不能因 member_map 直接索引而 KeyError。"""
|
||||
manager_b = _person(client, admin["headers"], role="sales_manager")
|
||||
manager_a = _person(
|
||||
client, admin["headers"], role="sales_manager", manager_id=manager_b["id"]
|
||||
)
|
||||
|
||||
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_a = next(t for t in data["teams"] if t["manager_id"] == manager_a["id"])
|
||||
assert team_a["member_count"] == 0
|
||||
team_b = next(t for t in data["teams"] if t["manager_id"] == manager_b["id"])
|
||||
assert all(m["personnel_id"] != manager_a["id"] for m in team_b["members"])
|
||||
|
||||
|
||||
def test_部门汇总_仅有调整单人员并入对应团队(admin, client: TestClient):
|
||||
"""只有调整单、当月无台账行的人也要出现在团队明细中。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
||||
_insert_adjustment(salesperson, _month(), "500.00")
|
||||
|
||||
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"])
|
||||
member = next(m for m in team["members"] if m["personnel_id"] == salesperson["id"])
|
||||
assert member["member_kind"] == "adjustment"
|
||||
assert Decimal(str(member["performance_sum"])) == Decimal("0")
|
||||
assert Decimal(str(member["adjust_net"])) == Decimal("500.00")
|
||||
assert Decimal(str(team["adjust_net"])) == Decimal("500.00")
|
||||
assert Decimal(str(data["reconciliation"]["adjust_net_total"])) == Decimal("500.00")
|
||||
|
||||
|
||||
def test_部门汇总_仅调整单无经理人员进入未分配(admin, client: TestClient):
|
||||
"""无经理人员的调整单不能被静默丢弃。"""
|
||||
salesperson = _person(client, admin["headers"])
|
||||
_insert_adjustment(salesperson, _month(), "300.00")
|
||||
|
||||
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"]
|
||||
row = next(u for u in data["unassigned"] if u["personnel_id"] == salesperson["id"])
|
||||
assert row["member_kind"] == "adjustment"
|
||||
assert Decimal(str(row["adjust_net"])) == Decimal("300.00")
|
||||
|
||||
|
||||
def test_部门汇总_经理个人户计入时基数等于成员合计(admin, client: TestClient):
|
||||
"""开关 1:经理成员行只取个人户金额,团队基数 == 成员绩效合计。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
_insert_manager_personal_ledger(manager, _month(), "100.00")
|
||||
_set_param(client, admin["headers"], "manager.personal.included", "1")
|
||||
try:
|
||||
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"]
|
||||
)
|
||||
assert Decimal(str(team["team_base"])) == Decimal("100.00")
|
||||
assert sum(
|
||||
(Decimal(str(m["performance_sum"])) for m in team["members"]), Decimal("0")
|
||||
) == Decimal("100.00")
|
||||
assert all(m["member_kind"] == "manager_personal" for m in team["members"])
|
||||
finally:
|
||||
_set_param(client, admin["headers"], "manager.personal.included", "0")
|
||||
|
||||
|
||||
def test_部门汇总_经理个人户关闭时不展示个人户金额(admin, client: TestClient):
|
||||
"""开关 0:个人户金额既不计入团队基数,也不进未分配(与全公司口径一致)。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
_insert_manager_personal_ledger(manager, _month(), "100.00")
|
||||
_set_param(client, admin["headers"], "manager.personal.included", "0")
|
||||
|
||||
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"])
|
||||
assert team["member_count"] == 0
|
||||
assert all(u["personnel_id"] != manager["id"] for u in data["unassigned"])
|
||||
|
||||
|
||||
def test_部门汇总_按经理筛选不误判其他团队下属(admin, client: TestClient):
|
||||
"""筛选只影响展示的团队;其他经理的下属不能进入未分配。"""
|
||||
manager_1 = _person(client, admin["headers"], role="sales_manager")
|
||||
manager_2 = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson_1 = _person(client, admin["headers"], manager_id=manager_1["id"])
|
||||
salesperson_2 = _person(client, admin["headers"], manager_id=manager_2["id"])
|
||||
_full_chain(client, admin["headers"], owner_id=salesperson_1["id"])
|
||||
_full_chain(client, admin["headers"], owner_id=salesperson_2["id"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
assert [t["manager_id"] for t in data["teams"]] == [manager_1["id"]]
|
||||
assert data["unassigned"] == []
|
||||
assert all(
|
||||
m["personnel_id"] != salesperson_2["id"]
|
||||
for team in data["teams"]
|
||||
for m in team["members"]
|
||||
)
|
||||
assert Decimal(str(data["reconciliation"]["other_team_base"])) > 0
|
||||
|
||||
|
||||
def test_部门汇总_对账恒等式(admin, client: TestClient):
|
||||
"""未筛选:团队基数 + 未分配绩效 = 全公司基数;应发公式成立。"""
|
||||
manager = _person(client, admin["headers"], role="sales_manager")
|
||||
salesperson = _person(client, admin["headers"], manager_id=manager["id"])
|
||||
unassigned = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=salesperson["id"])
|
||||
_full_chain(client, admin["headers"], owner_id=unassigned["id"])
|
||||
_insert_adjustment(salesperson, _month(), "200.00")
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
reconciliation = resp.json()["data"]["reconciliation"]
|
||||
assert Decimal(str(reconciliation["team_base_total"])) + Decimal(
|
||||
str(reconciliation["unassigned_performance_total"])
|
||||
) == Decimal(str(reconciliation["company_base"]))
|
||||
assert Decimal(str(reconciliation["other_team_base"])) == Decimal("0")
|
||||
assert Decimal(str(reconciliation["payable_total"])) == (
|
||||
Decimal(str(reconciliation["team_member_payable_total"]))
|
||||
+ Decimal(str(reconciliation["unassigned_payable_total"]))
|
||||
+ Decimal(str(reconciliation["aggregate_payable_total"]))
|
||||
+ Decimal(str(reconciliation["adjust_net_total"]))
|
||||
)
|
||||
# 分列合计 + 舍入差 = 聚合额(月结一次舍入口径)
|
||||
assert Decimal(str(reconciliation["aggregate_rounding_diff"])) == (
|
||||
Decimal(str(reconciliation["aggregate_payable_total"]))
|
||||
- Decimal(str(reconciliation["team_share_total"]))
|
||||
- Decimal(str(reconciliation["company_share_total"]))
|
||||
)
|
||||
|
||||
|
||||
def _two_manager_scene(
|
||||
client: TestClient, headers: dict
|
||||
) -> tuple[dict, dict, dict, dict, dict]:
|
||||
"""两位经理各有下属与业务 + 一位零成员经理(无聚合行)。"""
|
||||
manager_1 = _person(client, headers, role="sales_manager")
|
||||
manager_2 = _person(client, headers, role="sales_manager")
|
||||
empty_manager = _person(client, headers, role="sales_manager")
|
||||
salesperson_1 = _person(client, headers, manager_id=manager_1["id"])
|
||||
salesperson_2 = _person(client, headers, manager_id=manager_2["id"])
|
||||
_full_chain(client, headers, owner_id=salesperson_1["id"])
|
||||
_full_chain(client, headers, owner_id=salesperson_2["id"])
|
||||
return manager_1, manager_2, empty_manager, salesperson_1, salesperson_2
|
||||
|
||||
|
||||
def test_部门汇总_应发合计与月结对拍_两位经理(admin, client: TestClient):
|
||||
"""月结每位有聚合行的经理各计一次 5%;签字稿应发必须等于 total_payable。"""
|
||||
_two_manager_scene(client, admin["headers"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
before = resp.json()["data"]
|
||||
assert before["company"]["company_share_count"] == 2
|
||||
assert Decimal(str(before["company"]["company_share_total"])) == (
|
||||
Decimal(str(before["company"]["company_share_amount"])) * 2
|
||||
)
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement = resp.json()["data"]
|
||||
|
||||
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"]
|
||||
teams = {team["manager_id"]: team for team in data["teams"]}
|
||||
frozen_total = sum(
|
||||
(
|
||||
Decimal(str(team["frozen_amount"]))
|
||||
for team in teams.values()
|
||||
if team["frozen_amount"] is not None
|
||||
),
|
||||
Decimal("0"),
|
||||
)
|
||||
reconciliation = data["reconciliation"]
|
||||
assert Decimal(str(reconciliation["aggregate_payable_total"])) == frozen_total
|
||||
assert Decimal(str(reconciliation["payable_total"])) == Decimal(
|
||||
str(settlement["total_payable"])
|
||||
)
|
||||
|
||||
|
||||
def test_部门汇总_有调整单时应发等于月结加调整(admin, client: TestClient):
|
||||
"""调整单不进月结 items;报表应发 = 月结总额 + 调整净额。"""
|
||||
_, _, _, salesperson, _ = _two_manager_scene(client, admin["headers"])
|
||||
_insert_adjustment(salesperson, _month(), "500.00")
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
settlement = resp.json()["data"]
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
reconciliation = resp.json()["data"]["reconciliation"]
|
||||
assert Decimal(str(reconciliation["adjust_net_total"])) == Decimal("500.00")
|
||||
assert Decimal(str(reconciliation["payable_total"])) == (
|
||||
Decimal(str(settlement["total_payable"])) + Decimal("500.00")
|
||||
)
|
||||
|
||||
|
||||
def test_部门汇总_按经理筛选时5百分比只计该团队(admin, client: TestClient):
|
||||
"""筛选态只汇总展示团队;隐藏团队的金额进 other_*,不参与应发。"""
|
||||
manager_1, _, _, _, _ = _two_manager_scene(client, admin["headers"])
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/department-summary",
|
||||
params={"settlement_month": _month(), "manager_id": manager_1["id"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
data = resp.json()["data"]
|
||||
assert data["company"]["company_share_count"] == 1
|
||||
reconciliation = data["reconciliation"]
|
||||
assert Decimal(str(reconciliation["other_team_aggregate_total"])) > 0
|
||||
assert Decimal(str(reconciliation["other_team_company_share_total"])) > 0
|
||||
|
||||
|
||||
def test_导出_筛选页眉ID转名称与中文标签(admin, client: TestClient):
|
||||
"""页眉是签字稿追溯筛选范围的唯一凭据,ID 必须转名称。"""
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import BusinessRole
|
||||
|
||||
person = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=person["id"])
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
role_name = (
|
||||
session.query(BusinessRole.role_name)
|
||||
.filter(BusinessRole.role_code == "salesperson")
|
||||
.scalar()
|
||||
)
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/personal-detail/export",
|
||||
params={
|
||||
"settlement_month": _month(),
|
||||
"personnel_id": person["id"],
|
||||
"role_code": "salesperson",
|
||||
"customer_type": "personal",
|
||||
"product_category": "municipal_water",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
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 person["personnel_name"] in text
|
||||
assert f"人员 {person['personnel_name']}(#{person['id']})" in text
|
||||
assert "个人户" in text
|
||||
assert "市政水利" in text
|
||||
assert role_name in text
|
||||
|
||||
|
||||
def test_导出_回款维度sheet透传筛选条件(admin, client: TestClient):
|
||||
"""两个维度 sheet 的页眉不能固定写「全部」。"""
|
||||
from io import BytesIO
|
||||
|
||||
from openpyxl import load_workbook
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/payment-stats/export",
|
||||
params={"customer_type": "personal"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 200, resp.text
|
||||
workbook = load_workbook(BytesIO(resp.content))
|
||||
for sheet_name in ("客户类型对比", "产品类别对比"):
|
||||
text = "\n".join(
|
||||
str(cell.value)
|
||||
for row in workbook[sheet_name].iter_rows()
|
||||
for cell in row
|
||||
if cell.value is not None
|
||||
)
|
||||
assert "客户类型 个人户" in text
|
||||
assert "筛选条件:全部" not in text
|
||||
|
||||
|
||||
def test_导出超限_失败审计留痕(admin, client: TestClient, monkeypatch):
|
||||
"""超限 400 发生在审计上下文内,必须留下失败记录。"""
|
||||
from app.services import report_service
|
||||
|
||||
person = _person(client, admin["headers"])
|
||||
_full_chain(client, admin["headers"], owner_id=person["id"])
|
||||
monkeypatch.setattr(report_service, "REPORT_EXPORT_MAX_ROWS", 0)
|
||||
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/personal-detail/export",
|
||||
params={"settlement_month": _month()},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
log = _latest_export_log()
|
||||
assert log is not None
|
||||
assert log.success == 0
|
||||
assert "超过上限" in (log.error_message or "")
|
||||
|
||||
|
||||
def test_导出取数异常_失败审计留痕(admin, client: TestClient, monkeypatch):
|
||||
"""取数阶段的异常同样要留下失败记录。
|
||||
|
||||
TestClient 默认重抛服务端异常,这里直接调 service 层,聚焦审计落库。
|
||||
"""
|
||||
from app.core.db import get_session_factory
|
||||
from app.models import User
|
||||
from app.schemas.report import PersonalDetailQuery
|
||||
from app.services import report_service
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise RuntimeError("t_取数异常")
|
||||
|
||||
monkeypatch.setattr(report_service, "_export_payload", _boom)
|
||||
factory = get_session_factory()
|
||||
with factory() as session:
|
||||
operator = session.get(User, admin["id"])
|
||||
with pytest.raises(RuntimeError, match="t_取数异常"):
|
||||
report_service.export_report(
|
||||
session,
|
||||
"personal-detail",
|
||||
PersonalDetailQuery(settlement_month=_month()),
|
||||
operator=operator,
|
||||
)
|
||||
log = _latest_export_log()
|
||||
assert log is not None
|
||||
assert log.success == 0
|
||||
# 未预期异常按安全口径收敛为通用文案,摘要仍能定位到报表
|
||||
assert "导出个人绩效工资明细表" in (log.summary or "")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"path", ["/personal-detail", "/department-summary", "/payment-stats"]
|
||||
)
|
||||
def test_非法月份_查询返回400(admin, client: TestClient, path: str):
|
||||
"""非法月份必须是参数错误 400,不能落 500。"""
|
||||
resp = client.get(
|
||||
f"{_REPORTS}{path}",
|
||||
params={"settlement_month": "2026-13"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"code", ["personal-detail", "department-summary", "payment-stats"]
|
||||
)
|
||||
def test_非法月份_导出返回400(admin, client: TestClient, code: str):
|
||||
"""导出手工构造查询模型同样要转 400。"""
|
||||
resp = client.get(
|
||||
f"{_REPORTS}/{code}/export",
|
||||
params={"settlement_month": "2026-13"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
@ -749,3 +749,24 @@ class TestLockInterception:
|
||||
resp = client.post(f"{_CALC}/receipts/{unsettled['id']}/calc", headers=admin["headers"])
|
||||
assert resp.status_code == 400, resp.text
|
||||
assert "锁定" in resp.json()["message"]
|
||||
|
||||
|
||||
def test_非法月份_月结生成与调整单均返回400(client: TestClient, admin: dict) -> None:
|
||||
"""月份范围必须在 schema 拦住:`2026-13` 不能传到 monthrange 变成 500。"""
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/generate", json={"month": "2026-13"}, headers=admin["headers"]
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
resp = client.post(
|
||||
f"{_SETTLEMENTS}/adjustments",
|
||||
json={
|
||||
"settlement_month": "2026-13",
|
||||
"personnel_id": 1,
|
||||
"direction": 1,
|
||||
"amount": "100.00",
|
||||
"reason": "t_非法月份",
|
||||
},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.status_code == 400, resp.text
|
||||
|
||||
@ -209,3 +209,214 @@ class TestExcel模板:
|
||||
]
|
||||
text = _sheet_text(_build("business-stats", rows))
|
||||
assert "已完成" in text
|
||||
|
||||
|
||||
def _member(
|
||||
personnel_id: int,
|
||||
name: str,
|
||||
*,
|
||||
kind: str = "regular",
|
||||
performance: str = "0.00",
|
||||
payable: str = "0.00",
|
||||
adjust: str = "0.00",
|
||||
reason: str | None = None,
|
||||
) -> dict:
|
||||
return {
|
||||
"personnel_id": personnel_id,
|
||||
"personnel_name": name,
|
||||
"role_code": "salesperson",
|
||||
"is_probation": 0,
|
||||
"member_kind": kind,
|
||||
"unassigned_reason": reason,
|
||||
"performance_sum": Decimal(performance),
|
||||
"payable_sum": Decimal(payable),
|
||||
"adjust_extra": Decimal(adjust),
|
||||
"adjust_deduct": Decimal("0"),
|
||||
"adjust_net": Decimal(adjust),
|
||||
"net_total": Decimal(payable) + Decimal(adjust),
|
||||
"ledger_ids": [],
|
||||
}
|
||||
|
||||
|
||||
def _department_data(
|
||||
team_text: str = "12.5",
|
||||
company_text: str = "5",
|
||||
*,
|
||||
second_has_aggregate: bool = False,
|
||||
) -> dict:
|
||||
"""一份含成员、零成员团队与未分配人员的部门汇总出参。
|
||||
|
||||
``second_has_aggregate`` 打开后两位经理都有月结聚合行,用于验证
|
||||
全公司 5% 按人数累计(零成员团队默认不发 5%)。
|
||||
"""
|
||||
second_aggregate = Decimal("600.00") if second_has_aggregate else Decimal("0")
|
||||
share_count = 2 if second_has_aggregate else 1
|
||||
aggregate_total = Decimal("1850.00") + second_aggregate
|
||||
payable_total = Decimal("10000.00") + Decimal("2000.00") + aggregate_total
|
||||
return {
|
||||
"settlement_month": "2026-10",
|
||||
"params": {
|
||||
"team_text": team_text,
|
||||
"company_text": company_text,
|
||||
"manager_personal_included": 0,
|
||||
"params_as_of": "2026-10-31",
|
||||
},
|
||||
"teams": [
|
||||
{
|
||||
"manager_id": 1,
|
||||
"manager_name": "张经理",
|
||||
"manager_status": 1,
|
||||
"member_count": 1,
|
||||
"team_base": Decimal("10000.00"),
|
||||
"team_share_amount": Decimal("1250.00"),
|
||||
"company_share_amount": Decimal("600.00"),
|
||||
"has_aggregate": True,
|
||||
"aggregate_amount": Decimal("1850.00"),
|
||||
"team_subtotal": Decimal("1850.00"),
|
||||
"company_base_ratio": Decimal("0.8333"),
|
||||
"adjust_extra": Decimal("0"),
|
||||
"adjust_deduct": Decimal("0"),
|
||||
"adjust_net": Decimal("0"),
|
||||
"net_total": Decimal("1850.00"),
|
||||
"frozen_amount": None,
|
||||
"aggregate_ledger_id": None,
|
||||
"members": [
|
||||
_member(
|
||||
2,
|
||||
"李业务",
|
||||
performance="10000.00",
|
||||
payable="10000.00",
|
||||
)
|
||||
],
|
||||
},
|
||||
{
|
||||
"manager_id": 3,
|
||||
"manager_name": "王经理",
|
||||
"manager_status": 1,
|
||||
"member_count": 0,
|
||||
"team_base": Decimal("0"),
|
||||
"team_share_amount": Decimal("0"),
|
||||
"company_share_amount": second_aggregate,
|
||||
"has_aggregate": second_has_aggregate,
|
||||
"aggregate_amount": second_aggregate,
|
||||
"team_subtotal": second_aggregate,
|
||||
"company_base_ratio": Decimal("0"),
|
||||
"adjust_extra": Decimal("0"),
|
||||
"adjust_deduct": Decimal("0"),
|
||||
"adjust_net": Decimal("0"),
|
||||
"net_total": second_aggregate,
|
||||
"frozen_amount": None,
|
||||
"aggregate_ledger_id": None,
|
||||
"members": [],
|
||||
},
|
||||
],
|
||||
"unassigned": [
|
||||
_member(
|
||||
4,
|
||||
"赵未分配",
|
||||
performance="2000.00",
|
||||
payable="2000.00",
|
||||
reason="无经理或上级非营销经理",
|
||||
)
|
||||
],
|
||||
"company": {
|
||||
"team_base_total": Decimal("10000.00"),
|
||||
"company_base": Decimal("12000.00"),
|
||||
"company_share_pct": company_text,
|
||||
"company_share_amount": Decimal("600.00"),
|
||||
"company_share_count": share_count,
|
||||
"company_share_total": Decimal("600.00") * share_count,
|
||||
},
|
||||
"reconciliation": {
|
||||
"team_member_payable_total": Decimal("10000.00"),
|
||||
"unassigned_payable_total": Decimal("2000.00"),
|
||||
"team_share_total": Decimal("1250.00"),
|
||||
"company_share_total": Decimal("600.00") * share_count,
|
||||
"aggregate_payable_total": aggregate_total,
|
||||
"aggregate_rounding_diff": Decimal("0"),
|
||||
"adjust_net_total": Decimal("0"),
|
||||
"payable_total": payable_total,
|
||||
"team_base_total": Decimal("10000.00"),
|
||||
"unassigned_performance_total": Decimal("2000.00"),
|
||||
"other_team_base": Decimal("0"),
|
||||
"other_team_member_payable_total": Decimal("0"),
|
||||
"other_team_share_total": Decimal("0"),
|
||||
"other_team_company_share_total": Decimal("0"),
|
||||
"other_team_aggregate_total": Decimal("0"),
|
||||
"other_adjust_net_total": Decimal("0"),
|
||||
"company_base": Decimal("12000.00"),
|
||||
},
|
||||
"totals": {},
|
||||
}
|
||||
|
||||
|
||||
def _build_department(data: dict) -> bytes:
|
||||
return report_export.build_workbook(
|
||||
code="department-summary",
|
||||
title="部门绩效工资汇总表",
|
||||
filters="统计月份 2026-10",
|
||||
rows=[],
|
||||
summary=None,
|
||||
buckets=None,
|
||||
department=data,
|
||||
template_version="V1.2",
|
||||
operator_name="admin",
|
||||
company_name="衍水集团",
|
||||
)
|
||||
|
||||
|
||||
class Test部门汇总签字稿:
|
||||
"""签字稿少了团队分成/5% 块/未分配/零成员团队,合计就会小于应发。"""
|
||||
|
||||
def test_五块顺序完整(self):
|
||||
text = _sheet_text(_build_department(_department_data()))
|
||||
titles = [
|
||||
"一、团队分成汇总",
|
||||
"二、全公司分成(每位有聚合行",
|
||||
"三、对账汇总",
|
||||
"四、团队成员明细",
|
||||
"五、未分配人员明细",
|
||||
]
|
||||
positions = [text.index(title) for title in titles]
|
||||
assert positions == sorted(positions), "签字稿块顺序错乱"
|
||||
assert "财务复核" in text and "领导签字" in text
|
||||
|
||||
def test_零成员团队与未分配行存在(self):
|
||||
text = _sheet_text(_build_department(_department_data()))
|
||||
assert "王经理" in text
|
||||
assert "赵未分配" in text
|
||||
assert "无经理或上级非营销经理" in text
|
||||
|
||||
def test_全公司分成按计入次数累计(self):
|
||||
"""两位有聚合行的经理各计一次 5%;零成员团队不产生分成。"""
|
||||
one = _department_data()
|
||||
text = _sheet_text(_build_department(one))
|
||||
assert text.count("全公司分成(每位有聚合行") == 1
|
||||
assert "共 1 次" in text
|
||||
assert "13850" in text
|
||||
|
||||
two = _department_data(second_has_aggregate=True)
|
||||
text = _sheet_text(_build_department(two))
|
||||
assert "共 2 次" in text
|
||||
# 团队成员应付 10000 + 未分配 2000 + 聚合 2450
|
||||
assert "14450" in text
|
||||
|
||||
def test_应发合计取对账口径(self):
|
||||
text = _sheet_text(_build_department(_department_data()))
|
||||
assert "应发合计" in text
|
||||
# Excel 数值单元格读回为 int/float,金额断言只看数值本身
|
||||
assert "13850" in text
|
||||
|
||||
def test_聚合行舍入差单列(self):
|
||||
data = _department_data()
|
||||
data["reconciliation"]["aggregate_rounding_diff"] = Decimal("-0.02")
|
||||
text = _sheet_text(_build_department(data))
|
||||
assert "聚合行舍入差" in text
|
||||
assert "-0.02" in text
|
||||
|
||||
def test_比例标题参数化(self):
|
||||
text = _sheet_text(_build_department(_department_data("20", "6")))
|
||||
assert "团队分成(20%)" in text
|
||||
assert "6%" in text
|
||||
assert "12.5%" not in text
|
||||
assert "5%" not in text.replace("6%", "")
|
||||
|
||||
@ -23,6 +23,7 @@ import os
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from decimal import Decimal
|
||||
|
||||
from playwright.sync_api import Page, sync_playwright
|
||||
|
||||
@ -74,6 +75,15 @@ def step_setup_via_api(page: Page) -> dict:
|
||||
if not token:
|
||||
fail("API 预置", "localStorage 中未取到 ys_token")
|
||||
|
||||
# 回款统计断言取增量:脚本可重复运行(上次失败/历史 e2e 数据会累计到桶)
|
||||
baseline = _api(
|
||||
"GET", f"/api/v1/reports/payment-stats?settlement_month={MONTH}", token
|
||||
)
|
||||
baseline_bucket = next(b for b in baseline["buckets"] if b["code"] == "61-90")
|
||||
expected_receipt_amount = (
|
||||
Decimal(str(baseline_bucket["receipt_amount"])) + Decimal("10000.00")
|
||||
)
|
||||
|
||||
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)
|
||||
@ -159,8 +169,10 @@ def step_setup_via_api(page: Page) -> dict:
|
||||
return {
|
||||
"month": MONTH,
|
||||
"business_no": business["business_no"],
|
||||
"personnel_id": person["id"],
|
||||
"personnel_name": person["personnel_name"],
|
||||
"manager_name": manager["personnel_name"],
|
||||
"expected_receipt_amount": f"{expected_receipt_amount:.2f}",
|
||||
}
|
||||
|
||||
|
||||
@ -216,7 +228,7 @@ def step_business_stats(page: Page, ctx: dict) -> None:
|
||||
row = page.locator(".el-table__row", has_text=ctx["business_no"]).first
|
||||
if row.count() == 0:
|
||||
fail("业务统计", f"未找到业务 {ctx['business_no']}")
|
||||
print("✓ 6. 业务统计 关键字筛选命中")
|
||||
print("✓ 7. 业务统计 关键字筛选命中")
|
||||
|
||||
|
||||
def step_payment_stats(page: Page, ctx: dict) -> None:
|
||||
@ -225,16 +237,136 @@ def step_payment_stats(page: Page, ctx: dict) -> None:
|
||||
page.locator(".filters button:has-text('查询')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
rows = page.locator(".el-table__row")
|
||||
# 页面同时有区间表与两个维度对比表,必须限定主区间表
|
||||
rows = page.locator('[data-testid="payment-bucket-table"] .el-table__row')
|
||||
if rows.count() != 8:
|
||||
fail("回款统计", f"区间表应为 8 行,实际 {rows.count()}")
|
||||
bucket = page.locator(".el-table__row", has_text="61-90 天").first
|
||||
bucket = page.locator(
|
||||
'[data-testid="payment-bucket-table"] .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 档区间与目标桶金额正确")
|
||||
expected = ctx["expected_receipt_amount"]
|
||||
if expected not in bucket_text:
|
||||
fail("回款统计", f"61-90 天桶回款金额不符(期望含 {expected}):{bucket_text[:120]}")
|
||||
print("✓ 8. 回款统计 8 档区间与目标桶金额正确")
|
||||
|
||||
|
||||
def step_payment_multi_expand(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)
|
||||
|
||||
for label in ("61-90 天", "1-30 天"):
|
||||
row = page.locator(
|
||||
'[data-testid="payment-bucket-table"] .el-table__row', has_text=label
|
||||
).first
|
||||
row.locator(".el-table__expand-icon").click()
|
||||
page.wait_for_timeout(700)
|
||||
|
||||
drills = page.locator(".drill-wrap")
|
||||
if drills.count() != 2:
|
||||
fail("回款多桶展开", f"应展开 2 个下钻面板,实际 {drills.count()}")
|
||||
texts = [drills.nth(index).inner_text() for index in range(2)]
|
||||
# 面板是明细行(每笔 10000),不是桶合计;串行时两个面板文本会完全相同
|
||||
if texts[0] == texts[1] or "10000.00" not in "".join(texts):
|
||||
fail("回款多桶展开", "两个桶面板显示了同一批明细(drill 状态串行)")
|
||||
print("✓ 9. 回款多桶展开 两个下钻面板互不串行")
|
||||
|
||||
|
||||
def step_department_drill(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
|
||||
row.locator(".el-table__expand-icon").click()
|
||||
page.wait_for_timeout(500)
|
||||
page.locator("button:has-text('查看明细')").first.click()
|
||||
page.wait_for_timeout(900)
|
||||
|
||||
if f"personnel_id={ctx['personnel_id']}" not in page.url:
|
||||
fail("部门下钻", f"URL 未携带人员筛选:{page.url}")
|
||||
if ctx["personnel_name"] not in page.locator(".filters").inner_text():
|
||||
fail("部门下钻", "个人明细页未应用人员筛选")
|
||||
print("✓ 6. 部门下钻 人员与月份筛选已生效")
|
||||
|
||||
|
||||
def step_print_truncated(page: Page, ctx: dict) -> None:
|
||||
"""超过打印上限时必须给出截断提示(修复前提示永不出现)。"""
|
||||
|
||||
def handler(route) -> None:
|
||||
rows = [
|
||||
{
|
||||
"ledger_id": index,
|
||||
"business_no": f"E2E-{index}",
|
||||
"ship_date": None,
|
||||
"receipt_date": f"{ctx['month']}-01",
|
||||
"payment_days": 10,
|
||||
"product_category": "municipal_water",
|
||||
"customer_type": "personal",
|
||||
"role_name": "业务员",
|
||||
"rate_text": "1",
|
||||
"weight_r": 1,
|
||||
"performance_amount": "1.00",
|
||||
"payable_amount": "1.00",
|
||||
}
|
||||
for index in range(200)
|
||||
]
|
||||
route.fulfill(
|
||||
json={
|
||||
"code": 200,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"list": rows,
|
||||
"total": 2500,
|
||||
"pageNum": 1,
|
||||
"pageSize": 200,
|
||||
"summary": {"row_count": 2500},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
page.route("**/api/v1/reports/personal-detail*", handler)
|
||||
try:
|
||||
page.goto(
|
||||
f"{BASE_URL}/reports/print/personal-detail?settlement_month={ctx['month']}",
|
||||
wait_until="networkidle",
|
||||
)
|
||||
page.wait_for_timeout(1200)
|
||||
if page.locator(".truncated").count() == 0:
|
||||
fail("打印截断", "超过 2000 行未显示截断提示")
|
||||
if "2000" not in page.locator(".truncated").inner_text():
|
||||
fail("打印截断", "截断提示未说明上限行数")
|
||||
finally:
|
||||
page.unroute("**/api/v1/reports/personal-detail*", handler)
|
||||
print("✓ 11. 打印截断 超限提示可见")
|
||||
|
||||
|
||||
def step_print_department(page: Page, ctx: dict) -> None:
|
||||
"""部门汇总打印稿必须含团队分成、5% 块、对账、成员与未分配五块。"""
|
||||
page.goto(
|
||||
f"{BASE_URL}/reports/print/department-summary?settlement_month={ctx['month']}",
|
||||
wait_until="networkidle",
|
||||
)
|
||||
page.wait_for_timeout(1200)
|
||||
for testid in (
|
||||
"print-department-teams",
|
||||
"print-company-block",
|
||||
"print-reconciliation",
|
||||
"print-department-members",
|
||||
"print-unassigned",
|
||||
):
|
||||
if page.locator(f'[data-testid="{testid}"]').count() == 0:
|
||||
fail("部门打印", f"缺少区块 {testid}")
|
||||
if "财务复核" not in page.locator(".print-page").inner_text():
|
||||
fail("部门打印", "签字栏缺失")
|
||||
print("✓ 12. 部门打印 五块结构与签字栏完整")
|
||||
|
||||
|
||||
def step_print_view(page: Page, ctx: dict) -> None:
|
||||
@ -253,7 +385,7 @@ def step_print_view(page: Page, ctx: dict) -> None:
|
||||
page.wait_for_timeout(400)
|
||||
if page.evaluate("window.__printed") is not True:
|
||||
fail("打印视图", "window.print 未被调用")
|
||||
print("✓ 8. 打印视图 表格/签字栏/打印调用均正常")
|
||||
print("✓ 10. 打印视图 表格/签字栏/打印调用均正常")
|
||||
|
||||
|
||||
def run(page: Page) -> None:
|
||||
@ -266,9 +398,13 @@ def run(page: Page) -> None:
|
||||
ctx = step_setup_via_api(page)
|
||||
step_personal_detail(page, ctx)
|
||||
step_department_summary(page, ctx)
|
||||
step_department_drill(page, ctx)
|
||||
step_business_stats(page, ctx)
|
||||
step_payment_stats(page, ctx)
|
||||
step_payment_multi_expand(page, ctx)
|
||||
step_print_view(page, ctx)
|
||||
step_print_truncated(page, ctx)
|
||||
step_print_department(page, ctx)
|
||||
|
||||
if console_errors:
|
||||
print(f" (控制台错误 {len(console_errors)} 条:{console_errors[:2]})")
|
||||
|
||||
@ -45,13 +45,26 @@ export interface BusinessRoleUpdateParams {
|
||||
sort_order?: number
|
||||
}
|
||||
|
||||
/** 分页查询岗位角色(含停用) */
|
||||
/** 岗位下拉选项(登录即可;基础数据与报表筛选用,含停用) */
|
||||
export interface BusinessRoleOption {
|
||||
id: number
|
||||
role_code: string
|
||||
role_name: string
|
||||
status: number
|
||||
}
|
||||
|
||||
/** 分页查询岗位角色(含停用,仅管理员) */
|
||||
export function listBusinessRoles(
|
||||
params: BusinessRoleQueryParams,
|
||||
): Promise<PageData<BusinessRoleItem>> {
|
||||
return request({ url: '/v1/system/business-roles', method: 'get', params })
|
||||
}
|
||||
|
||||
/** 只读岗位选项:财务/领导做报表筛选时不走管理员接口 */
|
||||
export function listBusinessRoleOptions(): Promise<BusinessRoleOption[]> {
|
||||
return request({ url: '/v1/base/business-roles/options', method: 'get' })
|
||||
}
|
||||
|
||||
/** 新增岗位 */
|
||||
export function createBusinessRole(
|
||||
data: BusinessRoleCreateParams,
|
||||
|
||||
@ -47,6 +47,9 @@ export interface DepartmentMemberRow {
|
||||
personnel_name: string
|
||||
role_code: string
|
||||
is_probation: number
|
||||
/** regular 普通台账 / manager_personal 经理个人户 / adjustment 仅调整单 */
|
||||
member_kind: string
|
||||
unassigned_reason: string | null
|
||||
performance_sum: string | null
|
||||
payable_sum: string | null
|
||||
adjust_extra: string
|
||||
@ -63,7 +66,14 @@ export interface DepartmentTeamRow {
|
||||
member_count: number
|
||||
team_base: string
|
||||
team_share_amount: string
|
||||
/** 单经理 5% 名义值;无月结聚合行时为 0(不发放) */
|
||||
company_share_amount: string
|
||||
/** 本月是否有月结聚合行(决定是否发放 5%) */
|
||||
has_aggregate: boolean
|
||||
/** 月结聚合行应发(12.5%+5% 一次舍入);无聚合行时 0 */
|
||||
aggregate_amount: string
|
||||
/** 团队应发小计 = 聚合行金额 + 调整净额 */
|
||||
team_subtotal: string
|
||||
company_base_ratio: string
|
||||
adjust_extra: string
|
||||
adjust_deduct: string
|
||||
@ -74,6 +84,27 @@ export interface DepartmentTeamRow {
|
||||
members: DepartmentMemberRow[]
|
||||
}
|
||||
|
||||
/** 签字稿对账块:应发 = 成员应付 + 未分配应付 + 聚合行金额 + 调整净额 */
|
||||
export interface DepartmentReconciliation {
|
||||
team_member_payable_total: string
|
||||
unassigned_payable_total: string
|
||||
team_share_total: string
|
||||
company_share_total: string
|
||||
aggregate_payable_total: string
|
||||
aggregate_rounding_diff: string
|
||||
adjust_net_total: string
|
||||
payable_total: string
|
||||
team_base_total: string
|
||||
unassigned_performance_total: string
|
||||
other_team_base: string
|
||||
other_team_member_payable_total: string
|
||||
other_team_share_total: string
|
||||
other_team_company_share_total: string
|
||||
other_team_aggregate_total: string
|
||||
other_adjust_net_total: string
|
||||
company_base: string
|
||||
}
|
||||
|
||||
export interface DepartmentSummary {
|
||||
settlement_month: string
|
||||
params: {
|
||||
@ -88,8 +119,14 @@ export interface DepartmentSummary {
|
||||
team_base_total: string
|
||||
company_base: string
|
||||
company_share_pct: string
|
||||
/** 单经理 5% 名义值 */
|
||||
company_share_amount: string
|
||||
/** 计入应发的经理数(有聚合行) */
|
||||
company_share_count: number
|
||||
/** 5% 合计 = 单份 × 计入次数 */
|
||||
company_share_total: string
|
||||
} | null
|
||||
reconciliation: DepartmentReconciliation | null
|
||||
totals: Record<string, string>
|
||||
}
|
||||
|
||||
|
||||
@ -13,9 +13,11 @@ import { handledError } from '@/utils/error'
|
||||
* 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位
|
||||
* - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位
|
||||
*/
|
||||
export function usePagedList<T, F extends object>(
|
||||
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<PageData<T>>,
|
||||
export function usePagedList<T, F extends object, D extends PageData<T> = PageData<T>>(
|
||||
fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise<D>,
|
||||
getFilters: () => F,
|
||||
/** 仅最新请求的响应会触发(fetcher 内的副作用不受序号保护) */
|
||||
onData?: (data: D) => void,
|
||||
) {
|
||||
const records = ref([]) as Ref<T[]>
|
||||
const total = ref(0)
|
||||
@ -39,6 +41,7 @@ export function usePagedList<T, F extends object>(
|
||||
if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应
|
||||
records.value = data.list
|
||||
total.value = data.total
|
||||
onData?.(data)
|
||||
} catch (error) {
|
||||
handledError(error) // 失败提示由请求拦截器统一完成
|
||||
} finally {
|
||||
|
||||
@ -69,9 +69,9 @@ const managerOptions = ref<SelectOption[]>([])
|
||||
|
||||
async function loadRoleOptions(): Promise<void> {
|
||||
try {
|
||||
// 岗位规模有限(内置 3 个 + 自定义),一页足够
|
||||
const data = await businessRoleApi.listBusinessRoles({ pageNum: 1, pageSize: 100 })
|
||||
roleOptions.value = data.list.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||
// 岗位规模有限(内置 3 个 + 自定义),选项接口一次取全
|
||||
const data = await businessRoleApi.listBusinessRoleOptions()
|
||||
roleOptions.value = data.map((r) => ({ id: r.id, name: r.role_name, status: r.status }))
|
||||
} catch (error) {
|
||||
handledError(error) // 下拉为空不阻断列表使用
|
||||
}
|
||||
|
||||
@ -60,14 +60,15 @@ function getFilters(): ReportFilterParams {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(params: ReportFilterParams) {
|
||||
const data = await reportApi.listBusinessStats(params)
|
||||
summary.value = data.summary
|
||||
return data
|
||||
function fetchPage(params: ReportFilterParams) {
|
||||
return reportApi.listBusinessStats(params)
|
||||
}
|
||||
|
||||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<BusinessStatsRow, ReportFilterParams>(fetchPage, getFilters)
|
||||
usePagedList(fetchPage, getFilters, (data) => {
|
||||
summary.value = data.summary
|
||||
})
|
||||
|
||||
// 进页即加载(报表页默认展示全部,筛选由用户主动收窄)
|
||||
onMounted(search)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { reactive, ref } from 'vue'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
@ -14,8 +14,8 @@ import { handledError } from '@/utils/error'
|
||||
*
|
||||
* 部门口径 = personnel.manager_id 实时上溯的经理团队(无独立部门表);
|
||||
* 团队基数与月结聚合行同源(manager_share 共享模块),因此可与月结
|
||||
* 冻结额(frozen_amount)对账。全公司 5% 部分单列——各团队行展示的
|
||||
* 5% 金额是同一基数的名义值,不能相加。
|
||||
* 冻结额(frozen_amount)对账。全公司分成每位有聚合行的经理各计一次,
|
||||
* 团队应发小计与月结聚合行同口径;列头比例随在线参数变化。
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
@ -35,6 +35,18 @@ void loadManagers()
|
||||
const data = ref<DepartmentSummary | null>(null)
|
||||
const loading = ref(false)
|
||||
const exporting = ref(false)
|
||||
let loadSeq = 0
|
||||
|
||||
// 比例来自在线参数,列头/卡片不能写死 12.5% / 5%
|
||||
const teamShareLabel = computed(
|
||||
() => `团队分成(${data.value?.params?.team_text ?? '—'}%)`,
|
||||
)
|
||||
const companyShareLabel = computed(
|
||||
() => `全公司分成(${data.value?.params?.company_text ?? '—'}%)`,
|
||||
)
|
||||
const companyTitle = computed(
|
||||
() => `全公司 ${data.value?.params?.company_text ?? '—'}% 部分(单列)`,
|
||||
)
|
||||
|
||||
function currentMonth(): string {
|
||||
const now = new Date()
|
||||
@ -46,16 +58,18 @@ async function load(): Promise<void> {
|
||||
ElMessage.warning('请选择统计月份(部门汇总按单月聚合)')
|
||||
return
|
||||
}
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await reportApi.getDepartmentSummary({
|
||||
const result = await reportApi.getDepartmentSummary({
|
||||
settlement_month: filters.settlement_month,
|
||||
manager_id: filters.manager_id,
|
||||
})
|
||||
if (seq === loadSeq) data.value = result
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (seq === loadSeq) handledError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -164,11 +178,16 @@ function pct(value: string): string {
|
||||
</div>
|
||||
|
||||
<div v-loading="loading" class="company-card">
|
||||
<span class="company-title">全公司 5% 部分(单列)</span>
|
||||
<span class="company-title">{{ companyTitle }}</span>
|
||||
<span>全公司基数:{{ data?.company?.company_base ?? '—' }}</span>
|
||||
<span>5% 分成:{{ data?.company?.company_share_amount ?? '—' }}</span>
|
||||
<span>
|
||||
单经理 {{ data?.params?.company_text ?? '—' }}% 分成:
|
||||
{{ data?.company?.company_share_amount ?? '—' }} ×
|
||||
{{ data?.company?.company_share_count ?? 0 }} 人 =
|
||||
{{ data?.company?.company_share_total ?? '—' }}
|
||||
</span>
|
||||
<span class="company-note">
|
||||
各团队行的 5% 金额基于同一全公司基数,不可跨团队相加
|
||||
每位有月结聚合行的经理各计一次;零成员团队无聚合行,不产生分成
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@ -210,8 +229,8 @@ function pct(value: string): string {
|
||||
</el-table-column>
|
||||
<el-table-column prop="member_count" label="团队人数" width="90" align="right" />
|
||||
<el-table-column prop="team_base" label="团队基数" width="120" align="right" />
|
||||
<el-table-column prop="team_share_amount" label="12.5% 分成" width="120" align="right" />
|
||||
<el-table-column prop="company_share_amount" label="5% 分成" width="110" align="right" />
|
||||
<el-table-column prop="team_share_amount" :label="teamShareLabel" width="150" align="right" />
|
||||
<el-table-column prop="company_share_amount" :label="companyShareLabel" width="150" align="right" />
|
||||
<el-table-column label="占全公司比" width="110" align="right">
|
||||
<template #default="{ row }">{{ pct(row.company_base_ratio) }}</template>
|
||||
</el-table-column>
|
||||
|
||||
@ -40,66 +40,86 @@ function getFilters() {
|
||||
}
|
||||
}
|
||||
|
||||
interface DrillState {
|
||||
rows: PaymentDetailRow[]
|
||||
total: number
|
||||
pageNum: number
|
||||
pageSize: number
|
||||
loading: boolean
|
||||
seq: number
|
||||
}
|
||||
|
||||
/** 每个桶独立的下钻状态:同时展开多个区间时互不覆盖 */
|
||||
const drills = reactive<Record<string, DrillState>>({})
|
||||
|
||||
function drillOf(code: string): DrillState {
|
||||
drills[code] ??= { rows: [], total: 0, pageNum: 1, pageSize: 10, loading: false, seq: 0 }
|
||||
return drills[code]
|
||||
}
|
||||
|
||||
let loadSeq = 0
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const seq = ++loadSeq
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await reportApi.getPaymentStats(getFilters())
|
||||
const result = await reportApi.getPaymentStats(getFilters())
|
||||
if (seq !== loadSeq) return
|
||||
data.value = result
|
||||
// 筛选条件变化后旧桶明细不再对应当前口径;同时预建各桶状态,
|
||||
// 避免模板渲染时才创建响应式属性
|
||||
for (const code of Object.keys(drills)) delete drills[code]
|
||||
for (const bucket of result.buckets) drillOf(bucket.code)
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (seq === loadSeq) handledError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (seq === loadSeq) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
/** 桶展开下钻:分页明细按 (桶编码) 缓存当前页 */
|
||||
const drill = reactive({
|
||||
bucket: '',
|
||||
rows: [] as PaymentDetailRow[],
|
||||
total: 0,
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
loading: false,
|
||||
})
|
||||
|
||||
async function loadDrill(bucket: BucketRow, expanded: boolean): Promise<void> {
|
||||
if (!expanded) return
|
||||
drill.bucket = bucket.code
|
||||
drill.pageNum = 1
|
||||
await fetchDrill()
|
||||
function loadDrill(bucket: BucketRow, expandedRows: BucketRow[]): void {
|
||||
// Element Plus 2.x 的第二参是当前展开行数组,不是 boolean;
|
||||
// 收起时该桶不在数组里,自然不再请求
|
||||
if (!expandedRows.some((row) => row.code === bucket.code)) return
|
||||
drillOf(bucket.code).pageNum = 1
|
||||
void fetchDrill(bucket.code)
|
||||
}
|
||||
|
||||
async function fetchDrill(): Promise<void> {
|
||||
if (!drill.bucket) return
|
||||
drill.loading = true
|
||||
async function fetchDrill(code: string): Promise<void> {
|
||||
const state = drillOf(code)
|
||||
const seq = ++state.seq
|
||||
state.loading = true
|
||||
try {
|
||||
const page = await reportApi.listPaymentDetail({
|
||||
...getFilters(),
|
||||
bucket: drill.bucket,
|
||||
pageNum: drill.pageNum,
|
||||
pageSize: drill.pageSize,
|
||||
bucket: code,
|
||||
pageNum: state.pageNum,
|
||||
pageSize: state.pageSize,
|
||||
})
|
||||
drill.rows = page.list
|
||||
drill.total = page.total
|
||||
if (seq !== state.seq) return
|
||||
state.rows = page.list
|
||||
state.total = page.total
|
||||
} catch (error) {
|
||||
handledError(error)
|
||||
if (seq === state.seq) handledError(error)
|
||||
} finally {
|
||||
drill.loading = false
|
||||
if (seq === state.seq) state.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function onDrillPage(page: number): void {
|
||||
drill.pageNum = page
|
||||
void fetchDrill()
|
||||
function onDrillPage(code: string, page: number): void {
|
||||
drillOf(code).pageNum = page
|
||||
void fetchDrill(code)
|
||||
}
|
||||
|
||||
function onDrillSize(size: number): void {
|
||||
drill.pageSize = size
|
||||
drill.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||||
void fetchDrill()
|
||||
function onDrillSize(code: string, size: number): void {
|
||||
const state = drillOf(code)
|
||||
state.pageSize = size
|
||||
state.pageNum = 1 // 改每页条数回到第 1 页,否则可能停在空页
|
||||
void fetchDrill(code)
|
||||
}
|
||||
|
||||
void load()
|
||||
|
||||
async function doExport(): Promise<void> {
|
||||
exporting.value = true
|
||||
try {
|
||||
@ -198,15 +218,22 @@ function today(): string {
|
||||
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
class="bucket-table"
|
||||
data-testid="payment-bucket-table"
|
||||
:data="data?.buckets ?? []"
|
||||
row-key="code"
|
||||
stripe
|
||||
@expand-change="loadDrill"
|
||||
>
|
||||
<el-table-column type="expand">
|
||||
<template #default>
|
||||
<template #default="{ row }">
|
||||
<div class="drill-wrap">
|
||||
<el-table v-loading="drill.loading" :data="drill.rows" size="small" stripe>
|
||||
<el-table
|
||||
v-loading="drillOf(row.code).loading"
|
||||
:data="drillOf(row.code).rows"
|
||||
size="small"
|
||||
stripe
|
||||
>
|
||||
<el-table-column prop="business_no" label="合同编号" min-width="150" />
|
||||
<el-table-column prop="receipt_batch_no" label="回款批次号" width="160" />
|
||||
<el-table-column prop="receipt_date" label="回款日" width="110" />
|
||||
@ -226,11 +253,11 @@ function today(): string {
|
||||
<el-table-column prop="performance_amount" label="绩效金额" width="120" align="right" />
|
||||
</el-table>
|
||||
<AppPagination
|
||||
:total="drill.total"
|
||||
:page-num="drill.pageNum"
|
||||
:page-size="drill.pageSize"
|
||||
@page-change="onDrillPage"
|
||||
@size-change="onDrillSize"
|
||||
:total="drillOf(row.code).total"
|
||||
:page-num="drillOf(row.code).pageNum"
|
||||
:page-size="drillOf(row.code).pageSize"
|
||||
@page-change="onDrillPage(row.code, $event)"
|
||||
@size-change="onDrillSize(row.code, $event)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref, watch } from 'vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import * as calcApi from '@/api/calc'
|
||||
import type { LedgerRow } from '@/api/calc'
|
||||
@ -9,7 +9,7 @@ import { CUSTOMER_TYPE_OPTIONS, customerTypeLabel } from '@/api/customers'
|
||||
import { listPersonnel } from '@/api/personnel'
|
||||
import * as reportApi from '@/api/reports'
|
||||
import type { PersonalDetailRow, ReportFilterParams, ReportSummary } from '@/api/reports'
|
||||
import { listBusinessRoles } from '@/api/businessRoles'
|
||||
import { listBusinessRoleOptions } from '@/api/businessRoles'
|
||||
import AppPagination from '@/components/table/AppPagination.vue'
|
||||
import { useDropdownOptions } from '@/composables/useDropdownOptions'
|
||||
import { usePagedList } from '@/composables/usePagedList'
|
||||
@ -23,6 +23,7 @@ import { handledError } from '@/utils/error'
|
||||
*/
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const filters = reactive({
|
||||
settlement_month: '',
|
||||
@ -32,14 +33,30 @@ const filters = reactive({
|
||||
role_code: '',
|
||||
})
|
||||
|
||||
/**
|
||||
* 部门汇总「查看明细」下钻:路由 query 必须真正落到筛选条件上。
|
||||
*
|
||||
* 无 query 时也要清空(不能只覆盖):从菜单再次进入同一页面时组件复用,
|
||||
* 旧的 personnel_id/settlement_month 会残留,用户看到的仍是被下钻者的数据。
|
||||
*/
|
||||
function applyRouteQuery(): void {
|
||||
const personnelId = Number(route.query.personnel_id)
|
||||
filters.personnel_id =
|
||||
Number.isInteger(personnelId) && personnelId > 0 ? personnelId : undefined
|
||||
const month = route.query.settlement_month
|
||||
filters.settlement_month =
|
||||
typeof month === 'string' && /^\d{4}-\d{2}$/.test(month) ? month : ''
|
||||
}
|
||||
applyRouteQuery()
|
||||
|
||||
const { options: personnelOptions, load: loadPersonnel } = useDropdownOptions(() =>
|
||||
listPersonnel({ pageNum: 1, pageSize: 200, status: 1 }).then((data) =>
|
||||
data.list.map((item) => ({ label: item.personnel_name, value: item.id })),
|
||||
),
|
||||
)
|
||||
const { options: roleOptions, load: loadRoles } = useDropdownOptions(() =>
|
||||
listBusinessRoles({ pageNum: 1, pageSize: 200 }).then((data) =>
|
||||
data.list.map((item) => ({ label: item.role_name, value: item.role_code })),
|
||||
listBusinessRoleOptions().then((data) =>
|
||||
data.map((item) => ({ label: item.role_name, value: item.role_code })),
|
||||
),
|
||||
)
|
||||
void loadPersonnel()
|
||||
@ -58,18 +75,28 @@ function getFilters(): ReportFilterParams {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPage(params: ReportFilterParams) {
|
||||
const data = await reportApi.listPersonalDetail(params)
|
||||
summary.value = data.summary
|
||||
return data
|
||||
function fetchPage(params: ReportFilterParams) {
|
||||
return reportApi.listPersonalDetail(params)
|
||||
}
|
||||
|
||||
// summary 只能由最新请求写入:fetcher 内的副作用不受 usePagedList 序号保护
|
||||
const { records, total, pageNum, pageSize, loading, search, onPageChange, onSizeChange } =
|
||||
usePagedList<PersonalDetailRow, ReportFilterParams>(fetchPage, getFilters)
|
||||
usePagedList(fetchPage, getFilters, (data) => {
|
||||
summary.value = data.summary
|
||||
})
|
||||
|
||||
// 进页即加载(与月结/台账页修复后的行为一致;筛选条件默认为「全部」)
|
||||
// 进页即加载(筛选条件可能已由部门汇总下钻带入)
|
||||
onMounted(search)
|
||||
|
||||
// 同一路由再次下钻(例如从部门汇总换成员)时重新应用 query
|
||||
watch(
|
||||
() => route.query,
|
||||
() => {
|
||||
applyRouteQuery()
|
||||
search()
|
||||
},
|
||||
)
|
||||
|
||||
function resetFilters(): void {
|
||||
filters.settlement_month = ''
|
||||
filters.personnel_id = undefined
|
||||
|
||||
@ -7,7 +7,7 @@ import { customerTypeLabel } from '@/api/customers'
|
||||
import * as reportApi from '@/api/reports'
|
||||
import type {
|
||||
BusinessStatsRow,
|
||||
DepartmentMemberRow,
|
||||
DepartmentSummary,
|
||||
PersonalDetailRow,
|
||||
ReportFilterParams,
|
||||
} from '@/api/reports'
|
||||
@ -38,12 +38,30 @@ const title = ref('')
|
||||
// 表格行是四类报表的并集,模板按报表编码取用各自字段;用宽松记录类型
|
||||
// 避免为四种行形态各写一套模板 props
|
||||
const rows = ref<Record<string, any>[]>([])
|
||||
const department = ref<DepartmentSummary | null>(null)
|
||||
const buckets = ref<Record<string, any>[]>([])
|
||||
const groups = ref<{ title: string; rows: Record<string, any>[] }[]>([])
|
||||
const loading = ref(true)
|
||||
const truncated = ref(false)
|
||||
const printedAt = ref(new Date())
|
||||
|
||||
/** 部门汇总成员明细展平(团队列冗余,与签字稿其他块一致) */
|
||||
const departmentMembers = computed(() =>
|
||||
(department.value?.teams ?? []).flatMap((team) =>
|
||||
team.members.map((member) => ({ manager_name: team.manager_name, ...member })),
|
||||
),
|
||||
)
|
||||
|
||||
const MEMBER_KIND_LABELS: Record<string, string> = {
|
||||
regular: '普通绩效',
|
||||
manager_personal: '经理个人户',
|
||||
adjustment: '仅调整单',
|
||||
}
|
||||
|
||||
function memberKindLabel(kind: string): string {
|
||||
return MEMBER_KIND_LABELS[kind] ?? kind
|
||||
}
|
||||
|
||||
const filterText = computed(() => {
|
||||
const labels: Record<string, string> = {
|
||||
settlement_month: '统计月份',
|
||||
@ -71,13 +89,19 @@ async function fetchAll<T>(
|
||||
): Promise<{ list: T[]; truncated: boolean }> {
|
||||
const collected: T[] = []
|
||||
let pageNum = 1
|
||||
let total = 0
|
||||
while (collected.length < PRINT_MAX_ROWS) {
|
||||
const data = await fetcher({ ...params, pageNum, pageSize: PAGE_SIZE })
|
||||
total = data.total
|
||||
collected.push(...data.list)
|
||||
if (collected.length >= data.total || data.list.length < PAGE_SIZE) break
|
||||
pageNum += 1
|
||||
}
|
||||
return { list: collected.slice(0, PRINT_MAX_ROWS), truncated: collected.length > PRINT_MAX_ROWS }
|
||||
// 以接口 total 判定:collected 最多正好等于上限,用长度比较永远为 false
|
||||
return {
|
||||
list: collected.slice(0, PRINT_MAX_ROWS),
|
||||
truncated: total > PRINT_MAX_ROWS,
|
||||
}
|
||||
}
|
||||
|
||||
function personalRow(row: PersonalDetailRow): Record<string, any> {
|
||||
@ -109,18 +133,7 @@ async function load(): Promise<void> {
|
||||
truncated.value = data.truncated
|
||||
} else if (code.value === 'department-summary') {
|
||||
title.value = '部门绩效工资汇总表'
|
||||
const data = await reportApi.getDepartmentSummary(queryParams.value)
|
||||
rows.value = data.teams.flatMap((team) =>
|
||||
team.members.map((member: DepartmentMemberRow) => ({
|
||||
manager_name: team.manager_name,
|
||||
member_name: member.personnel_name,
|
||||
role_code: member.role_code,
|
||||
performance_sum: member.performance_sum,
|
||||
payable_sum: member.payable_sum,
|
||||
adjust_net: member.adjust_net,
|
||||
net_total: member.net_total,
|
||||
})),
|
||||
)
|
||||
department.value = await reportApi.getDepartmentSummary(queryParams.value)
|
||||
} else if (code.value === 'business-stats') {
|
||||
title.value = '业务统计表'
|
||||
const data = await fetchAll(
|
||||
@ -169,12 +182,13 @@ onMounted(load)
|
||||
<h1 class="title">{{ title }}</h1>
|
||||
<div class="meta">
|
||||
<span>筛选条件:{{ filterText }}</span>
|
||||
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.0</span>
|
||||
<span>打印时间:{{ printedAt.toLocaleString('zh-CN') }} | 模板版本 V1.2</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="truncated" class="truncated">
|
||||
数据量超过打印上限(2000 行),仅打印前 2000 行;请缩小筛选范围后重新打印。
|
||||
数据量超过打印上限({{ PRINT_MAX_ROWS }} 行),仅打印前 {{ PRINT_MAX_ROWS }}
|
||||
行;请缩小筛选范围后重新打印。
|
||||
</div>
|
||||
|
||||
<template v-if="code === 'personal-detail'">
|
||||
@ -204,23 +218,171 @@ onMounted(load)
|
||||
</table>
|
||||
</template>
|
||||
|
||||
<template v-else-if="code === 'department-summary'">
|
||||
<table class="sheet-table">
|
||||
<template v-else-if="code === 'department-summary' && department">
|
||||
<h2 class="block-title">一、团队分成汇总</h2>
|
||||
<table class="sheet-table" data-testid="print-department-teams">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>经理团队</th><th>成员</th><th>岗位</th><th>绩效金额</th>
|
||||
<th>应付绩效</th><th>调整净额</th><th>净合计</th>
|
||||
<th>序号</th><th>经理团队</th><th>状态</th><th>团队人数</th><th>团队基数</th>
|
||||
<th>团队分成({{ department.params?.team_text ?? '—' }}%)</th>
|
||||
<th>调整净额(仅核对)</th><th>团队应发小计(含聚合分成)</th><th>月结冻结额</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(row, index) in rows" :key="index">
|
||||
<td>{{ row.manager_name }}</td>
|
||||
<td>{{ row.member_name }}</td>
|
||||
<td>{{ row.role_code }}</td>
|
||||
<td class="num">{{ row.performance_sum }}</td>
|
||||
<td class="num">{{ row.payable_sum }}</td>
|
||||
<td class="num">{{ row.adjust_net }}</td>
|
||||
<td class="num">{{ row.net_total }}</td>
|
||||
<tr v-for="(team, index) in department.teams" :key="team.manager_id">
|
||||
<td>{{ index + 1 }}</td>
|
||||
<td>{{ team.manager_name }}</td>
|
||||
<td>{{ team.manager_status === 1 ? '在职' : '离职' }}</td>
|
||||
<td class="num">{{ team.member_count }}</td>
|
||||
<td class="num">{{ team.team_base }}</td>
|
||||
<td class="num">{{ team.team_share_amount }}</td>
|
||||
<td class="num">{{ team.adjust_net }}</td>
|
||||
<td class="num">{{ team.team_subtotal }}</td>
|
||||
<td class="num">{{ team.frozen_amount ?? '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">
|
||||
二、全公司分成(每位有聚合行的经理各计一次,共
|
||||
{{ department.company?.company_share_count ?? 0 }} 次)
|
||||
</h2>
|
||||
<table class="sheet-table" data-testid="print-company-block">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>全公司基数</th><th>分成比例</th><th>单经理分成</th>
|
||||
<th>计入次数</th><th>分成合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="num">{{ department.company?.company_base ?? '—' }}</td>
|
||||
<td class="num">{{ department.params?.company_text ?? '—' }}%</td>
|
||||
<td class="num">{{ department.company?.company_share_amount ?? '—' }}</td>
|
||||
<td class="num">{{ department.company?.company_share_count ?? 0 }}</td>
|
||||
<td class="num">{{ department.company?.company_share_total ?? '—' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">三、对账汇总</h2>
|
||||
<table class="sheet-table" data-testid="print-reconciliation">
|
||||
<thead>
|
||||
<tr><th>项目</th><th>金额</th><th>说明</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>团队成员应付合计</td>
|
||||
<td class="num">{{ department.reconciliation?.team_member_payable_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>未分配人员应付合计</td>
|
||||
<td class="num">{{ department.reconciliation?.unassigned_payable_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>团队分成合计(分列)</td>
|
||||
<td class="num">{{ department.reconciliation?.team_share_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>
|
||||
全公司分成合计({{ department.company?.company_share_count ?? 0 }} 次,分列)
|
||||
</td>
|
||||
<td class="num">{{ department.reconciliation?.company_share_total ?? '—' }}</td>
|
||||
<td>每位有聚合行的经理各计一次</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>聚合行舍入差</td>
|
||||
<td class="num">{{ department.reconciliation?.aggregate_rounding_diff ?? '—' }}</td>
|
||||
<td>分列舍入与聚合行一次舍入的差</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>调整净额合计(明细层)</td>
|
||||
<td class="num">{{ department.reconciliation?.adjust_net_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>应发合计</td>
|
||||
<td class="num">{{ department.reconciliation?.payable_total ?? '—' }}</td>
|
||||
<td>上列各项之和</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>团队基数合计</td>
|
||||
<td class="num">{{ department.reconciliation?.team_base_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>未分配绩效合计</td>
|
||||
<td class="num">{{ department.reconciliation?.unassigned_performance_total ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
<tr>
|
||||
<td>其他未展示基数</td>
|
||||
<td class="num">{{ department.reconciliation?.other_team_base ?? '—' }}</td>
|
||||
<td>筛选时含其他团队与全公司未分配</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>全公司基数</td>
|
||||
<td class="num">{{ department.reconciliation?.company_base ?? '—' }}</td>
|
||||
<td />
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">四、团队成员明细</h2>
|
||||
<table class="sheet-table" data-testid="print-department-members">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>经理团队</th><th>成员</th><th>岗位</th><th>来源</th><th>绩效金额</th>
|
||||
<th>应付绩效</th><th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="member in departmentMembers"
|
||||
:key="`${member.personnel_id}-${member.member_kind}`"
|
||||
>
|
||||
<td>{{ member.manager_name }}</td>
|
||||
<td>{{ member.personnel_name }}</td>
|
||||
<td>{{ member.role_code }}</td>
|
||||
<td>{{ memberKindLabel(member.member_kind) }}</td>
|
||||
<td class="num">{{ member.performance_sum }}</td>
|
||||
<td class="num">{{ member.payable_sum }}</td>
|
||||
<td class="num">{{ member.adjust_extra }}</td>
|
||||
<td class="num">{{ member.adjust_deduct }}</td>
|
||||
<td class="num">{{ member.adjust_net }}</td>
|
||||
<td class="num">{{ member.net_total }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h2 class="block-title">五、未分配人员明细</h2>
|
||||
<table class="sheet-table" data-testid="print-unassigned">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>人员</th><th>岗位</th><th>未纳入原因</th><th>绩效金额</th><th>应付绩效</th>
|
||||
<th>调整补发</th><th>调整扣减</th><th>调整净额</th><th>净合计</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="member in department.unassigned"
|
||||
:key="`${member.personnel_id}-${member.member_kind}`"
|
||||
>
|
||||
<td>{{ member.personnel_name }}</td>
|
||||
<td>{{ member.role_code }}</td>
|
||||
<td>{{ member.unassigned_reason ?? '—' }}</td>
|
||||
<td class="num">{{ member.performance_sum }}</td>
|
||||
<td class="num">{{ member.payable_sum }}</td>
|
||||
<td class="num">{{ member.adjust_extra }}</td>
|
||||
<td class="num">{{ member.adjust_deduct }}</td>
|
||||
<td class="num">{{ member.adjust_net }}</td>
|
||||
<td class="num">{{ member.net_total }}</td>
|
||||
</tr>
|
||||
<tr v-if="department.unassigned.length === 0">
|
||||
<td colspan="9">(无)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@ -374,6 +536,12 @@ onMounted(load)
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.block-title {
|
||||
margin: 18px 0 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.group-block {
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user