fix(report): 报表统计修复批次——对账口径、导出审计与月份校验

- 部门汇总全公司 5% 按每位有台账聚合行的营销经理各计一次,
  团队应发小计取聚合额 + 调整净额;与月结 total_payable 对拍
  (有调整单时 + adjust_net_total),分列舍入差单列
- 人员归属按行类型拆分(普通/经理个人户/仅调整单),调整单全量并入,
  团队与未分配不重复;按经理筛选只汇总展示团队
- 导出改单 sheet 分块签字稿,审计覆盖取数与超限;筛选页眉 ID→名称
- 月份校验抽 core/month,报表/月结/调整单非法月份统一 400;
  岗位只读选项供财务/领导筛选
- 前端修复:部门下钻带入筛选、回款多桶独立状态、打印截断判定、
  summary 请求序号保护

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
liuwenxinandClaude Code committed 2026-10-20 09:44:43 +08:00
1 parent 294d99a0d3
commit ebb3a92f25
21 files changed
+2377 -287

No files matched your search

+22 -9
View File
@@ -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
+4 -1
View File
@@ -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
+105 -5
View File
@@ -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="全表合计(成员/团队对账自证)")
+8 -7
View File
@@ -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)
+7 -1
View File
@@ -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])
+372 -24
View File
@@ -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
)
+579 -127
View File
@@ -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