From ebb3a92f259a571b6cde2a5dbedef53a292553e8 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Tue, 20 Oct 2026 09:44:43 +0800 Subject: [PATCH 1/8] =?UTF-8?q?fix(report):=20=E6=8A=A5=E8=A1=A8=E7=BB=9F?= =?UTF-8?q?=E8=AE=A1=E4=BF=AE=E5=A4=8D=E6=89=B9=E6=AC=A1=E2=80=94=E2=80=94?= =?UTF-8?q?=E5=AF=B9=E8=B4=A6=E5=8F=A3=E5=BE=84=E3=80=81=E5=AF=BC=E5=87=BA?= =?UTF-8?q?=E5=AE=A1=E8=AE=A1=E4=B8=8E=E6=9C=88=E4=BB=BD=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次, 团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍 (有调整单时 + adjust_net_total),分列舍入差单列 - 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入, 团队与未分配不重复;按经理筛选只汇总展示团队 - 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称 - 月份校验抽 core/month,报表/月结/调整单非法月份统一 400; 岗位只读选项供财务/领导筛选 - 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、 summary 请求序号保护 Co-Authored-By: Claude Code --- backend/app/api/v1/reports.py | 31 +- backend/app/core/report_meta.py | 5 +- backend/app/schemas/report.py | 110 ++- backend/app/schemas/settlement.py | 15 +- backend/app/services/manager_share.py | 8 +- backend/app/services/report_export.py | 396 +++++++++- backend/app/services/report_service.py | 706 ++++++++++++++---- .../tests/integration/test_business_roles.py | 11 +- backend/tests/integration/test_reports.py | 495 ++++++++++++ backend/tests/integration/test_settlements.py | 21 + backend/tests/unit/test_report_helpers.py | 211 ++++++ frontend/e2e/report_flow.py | 150 +++- frontend/src/api/businessRoles.ts | 15 +- frontend/src/api/reports.ts | 37 + frontend/src/composables/usePagedList.ts | 7 +- frontend/src/views/base/personnel/index.vue | 6 +- .../views/reports/business-stats/index.vue | 11 +- .../reports/department-summary/index.vue | 41 +- .../src/views/reports/payment-stats/index.vue | 115 +-- .../views/reports/personal-detail/index.vue | 49 +- frontend/src/views/reports/print/index.vue | 224 +++++- 21 files changed, 2377 insertions(+), 287 deletions(-) diff --git a/backend/app/api/v1/reports.py b/backend/app/api/v1/reports.py index 35dc384..3911571 100644 --- a/backend/app/api/v1/reports.py +++ b/backend/app/api/v1/reports.py @@ -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 diff --git a/backend/app/core/report_meta.py b/backend/app/core/report_meta.py index b7bc8d6..1aa92cc 100644 --- a/backend/app/core/report_meta.py +++ b/backend/app/core/report_meta.py @@ -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 diff --git a/backend/app/schemas/report.py b/backend/app/schemas/report.py index 2c3bfa5..542fcd0 100644 --- a/backend/app/schemas/report.py +++ b/backend/app/schemas/report.py @@ -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="全表合计(成员/团队对账自证)") diff --git a/backend/app/schemas/settlement.py b/backend/app/schemas/settlement.py index ef7afaf..5184c8d 100644 --- a/backend/app/schemas/settlement.py +++ b/backend/app/schemas/settlement.py @@ -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) diff --git a/backend/app/services/manager_share.py b/backend/app/services/manager_share.py index 0a24794..37cf1fe 100644 --- a/backend/app/services/manager_share.py +++ b/backend/app/services/manager_share.py @@ -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]) diff --git a/backend/app/services/report_export.py b/backend/app/services/report_export.py index dc63840..a99af0f 100644 --- a/backend/app/services/report_export.py +++ b/backend/app/services/report_export.py @@ -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 + ) diff --git a/backend/app/services/report_service.py b/backend/app/services/report_service.py index 6f24bd6..b4b35a5 100644 --- a/backend/app/services/report_service.py +++ b/backend/app/services/report_service.py @@ -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 diff --git a/backend/tests/integration/test_business_roles.py b/backend/tests/integration/test_business_roles.py index 62a943a..01a6528 100644 --- a/backend/tests/integration/test_business_roles.py +++ b/backend/tests/integration/test_business_roles.py @@ -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 diff --git a/backend/tests/integration/test_reports.py b/backend/tests/integration/test_reports.py index 68bff5c..076bb8b 100644 --- a/backend/tests/integration/test_reports.py +++ b/backend/tests/integration/test_reports.py @@ -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 diff --git a/backend/tests/integration/test_settlements.py b/backend/tests/integration/test_settlements.py index 51f586b..6c398c7 100644 --- a/backend/tests/integration/test_settlements.py +++ b/backend/tests/integration/test_settlements.py @@ -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 diff --git a/backend/tests/unit/test_report_helpers.py b/backend/tests/unit/test_report_helpers.py index f774c7c..6af9708 100644 --- a/backend/tests/unit/test_report_helpers.py +++ b/backend/tests/unit/test_report_helpers.py @@ -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%", "") diff --git a/frontend/e2e/report_flow.py b/frontend/e2e/report_flow.py index 1998fbf..5c8e3cc 100644 --- a/frontend/e2e/report_flow.py +++ b/frontend/e2e/report_flow.py @@ -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]})") diff --git a/frontend/src/api/businessRoles.ts b/frontend/src/api/businessRoles.ts index eaadf70..186327d 100644 --- a/frontend/src/api/businessRoles.ts +++ b/frontend/src/api/businessRoles.ts @@ -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> { return request({ url: '/v1/system/business-roles', method: 'get', params }) } +/** 只读岗位选项:财务/领导做报表筛选时不走管理员接口 */ +export function listBusinessRoleOptions(): Promise { + return request({ url: '/v1/base/business-roles/options', method: 'get' }) +} + /** 新增岗位 */ export function createBusinessRole( data: BusinessRoleCreateParams, diff --git a/frontend/src/api/reports.ts b/frontend/src/api/reports.ts index fae54b9..a9f7fe2 100644 --- a/frontend/src/api/reports.ts +++ b/frontend/src/api/reports.ts @@ -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 } diff --git a/frontend/src/composables/usePagedList.ts b/frontend/src/composables/usePagedList.ts index 936701d..91297c6 100644 --- a/frontend/src/composables/usePagedList.ts +++ b/frontend/src/composables/usePagedList.ts @@ -13,9 +13,11 @@ import { handledError } from '@/utils/error' * 请求的响应允许写入状态,否则旧数据会覆盖新数据、与分页器错位 * - 请求失败不外抛(提示由拦截器统一完成),loading 仅由最新请求复位 */ -export function usePagedList( - fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise>, +export function usePagedList = PageData>( + fetcher: (params: F & { pageNum: number; pageSize: number }) => Promise, getFilters: () => F, + /** 仅最新请求的响应会触发(fetcher 内的副作用不受序号保护) */ + onData?: (data: D) => void, ) { const records = ref([]) as Ref const total = ref(0) @@ -39,6 +41,7 @@ export function usePagedList( if (seq !== requestSeq) return // 已有更新的请求发出,丢弃过期响应 records.value = data.list total.value = data.total + onData?.(data) } catch (error) { handledError(error) // 失败提示由请求拦截器统一完成 } finally { diff --git a/frontend/src/views/base/personnel/index.vue b/frontend/src/views/base/personnel/index.vue index 8858df8..932eeee 100644 --- a/frontend/src/views/base/personnel/index.vue +++ b/frontend/src/views/base/personnel/index.vue @@ -69,9 +69,9 @@ const managerOptions = ref([]) async function loadRoleOptions(): Promise { 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) // 下拉为空不阻断列表使用 } diff --git a/frontend/src/views/reports/business-stats/index.vue b/frontend/src/views/reports/business-stats/index.vue index 69192b7..ffb4a44 100644 --- a/frontend/src/views/reports/business-stats/index.vue +++ b/frontend/src/views/reports/business-stats/index.vue @@ -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(fetchPage, getFilters) + usePagedList(fetchPage, getFilters, (data) => { + summary.value = data.summary + }) // 进页即加载(报表页默认展示全部,筛选由用户主动收窄) onMounted(search) diff --git a/frontend/src/views/reports/department-summary/index.vue b/frontend/src/views/reports/department-summary/index.vue index 9ecfa38..84b2a1d 100644 --- a/frontend/src/views/reports/department-summary/index.vue +++ b/frontend/src/views/reports/department-summary/index.vue @@ -1,5 +1,5 @@