打通 T+ 快照、业务、发货、回款和核算准备链路,保留来源差异并提供待补状态。 新增业务自动接入任务、跨平台读取服务托管、权限复核、来源关联和前端核对页面。 补充迁移、隔离集成测试、浏览器模拟验收及角色授权边界修复。
374 lines
16 KiB
Python
374 lines
16 KiB
Python
"""报表统计的请求/响应模型(M4 ⑤)。
|
||
|
||
金额与比例一律 `Decimal`(pydantic v2 序列化为字符串,零精度损失);
|
||
比例原样输出 `rate_text`(如 88/9000),禁止转 float 或截断展示。
|
||
"""
|
||
|
||
from datetime import date
|
||
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
|
||
|
||
|
||
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)
|
||
|
||
|
||
# ==== 查询入参 ====
|
||
|
||
|
||
class PersonalDetailQuery(PageQuery):
|
||
"""个人绩效明细查询条件(FR-029:人员/月份/客户类型/产品类别/岗位)。"""
|
||
|
||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||
personnel_id: int | None = Field(default=None, ge=1, description="人员筛选")
|
||
customer_type: str | None = Field(default=None, max_length=32, description="客户类型筛选")
|
||
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):
|
||
"""部门汇总查询条件(月份必填——部门表按单月聚合,无跨月视角)。"""
|
||
|
||
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):
|
||
"""业务统计查询条件;回款/绩效为该业务累计值,不随业务日期筛选而变。"""
|
||
|
||
keyword: str | None = Field(default=None, max_length=64, description="业务编号/客户名称")
|
||
customer_id: int | None = Field(default=None, ge=1, description="客户筛选")
|
||
owner_personnel_id: int | None = Field(default=None, ge=1, description="归属人筛选")
|
||
status: int | None = Field(default=None, description="业务状态筛选")
|
||
date_from: date | None = Field(default=None, description="业务日期起")
|
||
date_to: date | None = Field(default=None, description="业务日期止")
|
||
|
||
|
||
class PaymentStatsQuery(BaseModel):
|
||
"""回款统计查询条件(按回款天数区间/客户类型/产品类别统计,FR-031)。"""
|
||
|
||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||
date_from: date | None = Field(default=None, description="回款日期起")
|
||
date_to: date | None = Field(default=None, description="回款日期止")
|
||
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 页面展开行携带)。"""
|
||
|
||
bucket: str = Field(min_length=1, max_length=16, description="区间桶编码")
|
||
settlement_month: str | None = Field(default=None, max_length=7, description="归属月 YYYY-MM")
|
||
date_from: date | None = Field(default=None, description="回款日期起")
|
||
date_to: date | None = Field(default=None, description="回款日期止")
|
||
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 ReportSummary(BaseModel):
|
||
"""全筛选口径合计(与「当前页合计」区分,前端页脚需注明)。
|
||
|
||
字段按报表取用:个人明细用绩效/应付/折算毛利;业务统计用发货/回款/
|
||
销售额;不涉及的留 None。
|
||
"""
|
||
|
||
row_count: int = Field(default=0, description="筛选结果行数")
|
||
performance_total: Decimal | None = Field(default=None, description="绩效金额合计")
|
||
payable_total: Decimal | None = Field(default=None, description="应付绩效合计")
|
||
converted_profit_total: Decimal | None = Field(default=None, description="折算毛利润合计")
|
||
ship_total: Decimal | None = Field(default=None, description="发货额合计")
|
||
received_total: Decimal | None = Field(default=None, description="回款额合计")
|
||
sale_total: Decimal | None = Field(default=None, description="销售额合计")
|
||
sale_pending_count: int = Field(default=0, description="销售额待核对业务数")
|
||
|
||
|
||
class ReportPage[T](BaseModel):
|
||
"""报表分页:标准分页四字段 + 全筛选合计。
|
||
|
||
序列化字段与 PageResult 一致(list/total/pageNum/pageSize),前端
|
||
usePagedList 可直接消费;追加的 summary 由各页页脚单独读取。
|
||
"""
|
||
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
|
||
records: list[T] = Field(
|
||
default_factory=list, alias="list", serialization_alias="list", description="当前页数据"
|
||
)
|
||
total: int = Field(default=0, description="总记录数")
|
||
page_num: int = Field(
|
||
default=1, alias="pageNum", serialization_alias="pageNum", description="当前页码"
|
||
)
|
||
page_size: int = Field(
|
||
default=10, alias="pageSize", serialization_alias="pageSize", description="每页条数"
|
||
)
|
||
summary: ReportSummary = Field(default_factory=ReportSummary, description="全筛选合计")
|
||
|
||
|
||
class PersonalDetailRow(BaseModel):
|
||
"""个人绩效明细行(需规 2.4.1 列全覆盖)。
|
||
|
||
二次分摊拆行后同一回款按人多行(负责人余额行 + 参与人份额行),行内
|
||
不放「关联回款金额」列——同回款金额重复展示会让合计无法自洽,金额
|
||
下沉到快照下钻。
|
||
"""
|
||
|
||
ledger_id: int
|
||
business_id: int
|
||
business_no: str
|
||
personnel_id: int
|
||
personnel_name: str
|
||
role_code: str
|
||
role_name: str
|
||
customer_type: str
|
||
product_category: str
|
||
ship_date: date | None = None
|
||
receipt_date: date
|
||
payment_days: int | None = None
|
||
rate_text: str
|
||
weight_r: int | None = None
|
||
payment_weight: Decimal | None = None
|
||
performance_amount: Decimal | None = None
|
||
payable_amount: Decimal | None = None
|
||
converted_profit: Decimal | None = None
|
||
is_probation: int
|
||
missing_cost: int
|
||
calc_version: int
|
||
settlement_id: int | None = None
|
||
|
||
|
||
class DepartmentMemberRow(BaseModel):
|
||
"""部门汇总 · 团队成员明细(内联于团队行)。"""
|
||
|
||
personnel_id: int
|
||
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="调整单生效补发合计")
|
||
adjust_deduct: Decimal = Field(default=Decimal("0"), description="调整单生效扣减合计")
|
||
adjust_net: Decimal = Field(default=Decimal("0"), description="调整净额(补发为正)")
|
||
net_total: Decimal | None = None
|
||
ledger_ids: list[int] = Field(default_factory=list, description="台账行 id 列表(下钻锚点)")
|
||
|
||
|
||
class DepartmentTeamRow(BaseModel):
|
||
"""部门汇总 · 经理团队行。"""
|
||
|
||
manager_id: int
|
||
manager_name: str
|
||
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% 名义值;该团队无月结聚合行时为 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="团队净额 = 团队应发小计")
|
||
frozen_amount: Decimal | None = Field(default=None, description="月结聚合行冻结额(对照)")
|
||
aggregate_ledger_id: int | None = None
|
||
members: list[DepartmentMemberRow] = Field(default_factory=list)
|
||
|
||
|
||
class DepartmentCompanyBlock(BaseModel):
|
||
"""部门汇总 · 全公司口径块(FR-030「全公司 5% 部分单列」)。"""
|
||
|
||
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_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):
|
||
"""部门绩效工资汇总响应(FR-030)。"""
|
||
|
||
settlement_month: str
|
||
salary_rows: list[dict] = Field(default_factory=list, description="已生成工资的基本工资、正常绩效、缓发及年度补发快照")
|
||
params: dict | None = Field(default=None, description="分成参数原文与取值时点(对账用)")
|
||
teams: list[DepartmentTeamRow] = Field(default_factory=list)
|
||
unassigned: list[DepartmentMemberRow] = Field(
|
||
default_factory=list, description="无经理人员的绩效(不参与分成,仅展示)"
|
||
)
|
||
company: DepartmentCompanyBlock | None = None
|
||
reconciliation: DepartmentReconciliation | None = Field(
|
||
default=None, description="签字稿对账块(应发公式各项)"
|
||
)
|
||
totals: dict = Field(default_factory=dict, description="全表合计(成员/团队对账自证)")
|
||
|
||
|
||
class BusinessStatsRow(BaseModel):
|
||
"""业务统计行(发货/回款/绩效为该业务累计值)。"""
|
||
|
||
business_id: int
|
||
business_no: str
|
||
business_date: date | None = None
|
||
status: int
|
||
customer_name: str | None = None
|
||
owner_personnel_name: str | None = None
|
||
total_sale_amount: Decimal | None = None
|
||
total_ship_amount: Decimal | None = None
|
||
total_received_amount: Decimal | None = None
|
||
performance_amount: Decimal | None = None
|
||
payable_amount: Decimal | None = None
|
||
|
||
|
||
class BucketRow(BaseModel):
|
||
"""回款统计 · 区间桶行(空桶恒在且为 0,防金额静默消失)。"""
|
||
|
||
code: str
|
||
label: str
|
||
min_days: int | None = None
|
||
max_days: int | None = None
|
||
receipt_amount: Decimal | None = None
|
||
receipt_count: int = 0
|
||
performance_amount: Decimal | None = None
|
||
payable_amount: Decimal | None = None
|
||
ledger_count: int = 0
|
||
|
||
|
||
class GroupRow(BaseModel):
|
||
"""回款统计 · 维度对比行(客户类型 / 产品类别)。"""
|
||
|
||
code: str
|
||
label: str
|
||
receipt_amount: Decimal | None = None
|
||
performance_amount: Decimal | None = None
|
||
payable_amount: Decimal | None = None
|
||
ledger_count: int = 0
|
||
|
||
|
||
class PaymentStatsOut(BaseModel):
|
||
"""回款情况统计响应(FR-031)。
|
||
|
||
口径:已计提回款(有台账行)的金额与绩效;进度登记回款无台账行,
|
||
不在本表(页面需注明)。
|
||
"""
|
||
|
||
settlement_month: str | None = None
|
||
buckets: list[BucketRow] = Field(default_factory=list)
|
||
by_customer_type: list[GroupRow] = Field(default_factory=list)
|
||
by_product_category: list[GroupRow] = Field(default_factory=list)
|
||
totals: dict = Field(default_factory=dict, description="全表合计")
|
||
|
||
|
||
class PaymentDetailRow(BaseModel):
|
||
"""回款区间下钻行(行级展示回款金额与批次号)。"""
|
||
|
||
ledger_id: int
|
||
receipt_id: int | None = None
|
||
receipt_batch_no: str | None = None
|
||
amount: Decimal | None = Field(default=None, description="回款金额(同回款拆行重复展示)")
|
||
business_id: int
|
||
business_no: str
|
||
personnel_id: int
|
||
personnel_name: str
|
||
customer_type: str
|
||
product_category: str
|
||
ship_date: date | None = None
|
||
receipt_date: date
|
||
payment_days: int | None = None
|
||
performance_amount: Decimal | None = None
|
||
payable_amount: Decimal | None = None
|