"""报表统计的请求/响应模型(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="销售额合计") 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 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