diff --git a/.gitignore b/.gitignore index e54ba25..bee5538 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,9 @@ CLAUDE.md backend/uploads/ # 数据库备份导出目录(⑨ 备份脚本产物) db/backups/ + +# 外部参考资料(2026-10-20):T+12.3 数据字典压缩包与解压目录不入版本库 +docs/**/*.rar +docs/T_12.3数据字典/ +# macOS 压缩包残留元数据 +__MACOSX/ diff --git a/backend/README.md b/backend/README.md index 4f89b82..09cba71 100644 --- a/backend/README.md +++ b/backend/README.md @@ -224,7 +224,10 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是 ### 工作台口径(M4 ⑧,改动前先读) - **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。 -- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定;回款未指定批次 = `receipts.shipment_id IS NULL`。 +- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定,**已终止业务(status=3)排除**;回款未指定批次 = `receipts.shipment_id IS NULL`。 +- **待办按角色过滤**:`pending_statuses_for_roles`(approval_service)把当前用户角色映射到在途节点——财务只看待财务复核(status=1)、领导只看待领导审核(status=2)、admin 可看全部;多角色取并集。风险预警仍是全局经营视角,不按人裁剪。 +- **预警展示上限**:`items` 最多 20 条,`total` 是全部命中数(前端徽标用它,不能用 `items.length`)。 +- **趋势窗口**:回款趋势先按 `receipt_date` 范围过滤再按 `DATE_FORMAT` 分组,函数列不参与 WHERE,保证索引可用。 - **同步降级**:⑥ 未建时 `sync_available=false`,前端显示「模块未启用」——不要返回 0 条冒充「无失败」。 - **参数快照一次解析**:`warning.*` 三个阈值在每次请求只 `resolve_snapshot` 一次,多条规则复用。 - **导出 SQL 的编码坑**:Windows 下 `alembic upgrade --sql > V*.sql` 默认按控制台 GBK 输出,`test_schema_sync` 按 UTF-8 读取会报 `UnicodeDecodeError`——必须 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1` 前缀。 @@ -234,10 +237,12 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是 - **数据源固定单笔生效行**:四张报表的明细一律 `performance_ledgers.is_active=1 AND calc_type=1`;经理聚合行(`calc_type=2`)**只在部门汇总作冻结对照**(`frozen_amount`)单读。聚合行 `receipt_id` 为 NULL、`payment_days` 为 NULL,混入明细会让合同编号/天数列失真,业务统计的绩效也会被整团队聚合额污染。 - **调整单并入**:仅 `status=2`(生效),按 `(settlement_month, personnel_id)` 并入,**不要求该人当月有台账行**(只有调整单也要出零绩效成员行);`direction=1` 补发记 +、`2` 扣减记 −(金额恒正)。待审批/驳回/撤回的金额绝不能进报表——`report_repository.list_month_adjustments` 与调整单管理页的 `list_adjustments` 是两个口径。调整单归属跟随该人普通行的实时 `manager_id`;只有经理个人户的经理跟随本人团队。 - **回款金额按回款单去重**:二次分摊拆行使同一回款在台账出现多行(负责人 + 参与人),回款统计的金额侧必须 `DISTINCT receipt_id` 后再求和,否则按拆行数翻倍。个人明细页不放「回款金额」列(同一金额重复展示会让合计无法自洽),金额下沉到快照下钻。 -- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块),**12.5% 与 5% 分列**——两项之和才是聚合行金额。 +- **部门口径** = `personnel.manager_id` 实时上溯(无独立部门表);离职人员不按 `status` 过滤,历史业绩留在原团队;无成员经理也出 0 值行(在职经理枚举 ∪ 数据中出现的经理)。团队基数与月结聚合行同源(`services/manager_share.py` 共享模块)。 +- **全公司 5% 的发放口径**:月结只为有台账 bucket 的营销经理各生成一条聚合行(12.5%+5%),**每位经理各计一次 5%**;零成员团队无聚合行、不发 5%。报表的 `aggregate_amount` 优先取冻结聚合行、未生成月结时才用 `manager_team_share` 一次舍入预览;`team_subtotal`/`net_total` = 聚合额 + 调整净额。`team_share_total` 与 `company_share_total` 仅分列展示,两者之和与聚合额的差记入 `aggregate_rounding_diff`(分列各自 q2 会有 0.01~0.02 尾差)。 +- **对拍边界**:报表应发 = 月结 `total_payable` + 调整单净额(调整单不进月结 items);筛选 `manager_id` 时只汇总展示团队,隐藏金额进 `other_*`,不能与全公司月结总额对拍。 - **人员归属按行类型**:普通台账行跟随 `manager_id`;经理个人户行在 `manager.personal.included=1` 时归经理本人团队,且经理成员行**只取个人户金额**(其普通行业绩留在上级团队或未分配),保证团队基数 = 成员绩效合计。岗位判定范围必须覆盖所有成员引用的上级,否则其他经理的下属会被误判为「无经理」。按 `manager_id` 筛选时 `unassigned` 为空(语义是「仅看该经理团队」)。 - **回款天数区间**是 8 档代码常量(`core/report_meta.py`,含「≤0 天先款后货」与「无天数」兜底桶),服务端下发元数据、前端禁止硬编码;空桶恒在且为 0。进度登记回款无台账行,不在回款统计口径内(页面注明)。 -- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);全公司分成只出现一次,调整净额只在明细层求和,应发合计 = 团队成员应付 + 未分配应付 + 团队分成合计 + 全公司分成 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。 +- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);第二块展示单经理 5%、计入次数与合计,对账块展示团队分成/全公司分成(分列)、聚合行舍入差、调整净额,应发合计 = 团队成员应付 + 未分配应付 + 各经理聚合行金额 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。 - **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。 - **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。 - **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。 diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py index c9d7077..b0f492a 100644 --- a/backend/app/api/v1/dashboard.py +++ b/backend/app/api/v1/dashboard.py @@ -30,10 +30,15 @@ _DashboardUser = Annotated[ @router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块") def get_dashboard( session: DbSession, - _: _DashboardUser, + current_user: _DashboardUser, month: Annotated[ str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月") ] = None, ) -> ApiResponse[DashboardOut]: - """一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。""" - return ok(dashboard_service.get_dashboard(session, month)) + """一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。 + + 待办按当前用户角色只返回其可操作节点(admin 可查看全部在途单)。 + """ + return ok( + dashboard_service.get_dashboard(session, month, current_user=current_user) + ) 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/repositories/dashboard_repository.py b/backend/app/repositories/dashboard_repository.py index 2b4d0cf..179a03c 100644 --- a/backend/app/repositories/dashboard_repository.py +++ b/backend/app/repositories/dashboard_repository.py @@ -7,7 +7,7 @@ from datetime import date, datetime -from sqlalchemy import exists, func, select +from sqlalchemy import exists, func, or_, select from sqlalchemy.orm import Session from app.models import ( @@ -66,12 +66,18 @@ def performance_trend(session: Session, months: list[str]) -> dict[str, object]: return dict(rows) -def receipt_trend(session: Session, months: list[str]) -> dict[str, object]: - """近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。""" +def receipt_trend( + session: Session, *, start_date: date, end_date: date +) -> dict[str, object]: + """近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。 + + 过滤走 ``receipt_date`` 原始列范围(可用索引);``DATE_FORMAT`` 只用于 + 分组,不参与 WHERE,避免函数列导致全表扫描。 + """ month_expr = func.date_format(Receipt.receipt_date, "%Y-%m") rows = session.execute( select(month_expr, func.coalesce(func.sum(Receipt.amount), 0)) - .where(month_expr.in_(months)) + .where(Receipt.receipt_date >= start_date, Receipt.receipt_date < end_date) .group_by(month_expr) ).all() return dict(rows) @@ -101,24 +107,33 @@ def get_month_settlement(session: Session, month: str) -> MonthlySettlement | No # ==== 待办事项 ==== -def list_pending_approvals(session: Session, *, limit: int) -> list[ApprovalOrder]: - """待处理审批单(按提交时间升序——最久的排前面)。""" +def list_pending_approvals( + session: Session, *, statuses: tuple[int, ...], limit: int +) -> list[ApprovalOrder]: + """待处理审批单(按提交时间升序——最久的排前面)。 + + ``statuses`` 由调用方按当前用户角色解析(待我审批),空元组返回空列表。 + """ + if not statuses: + return [] return list( session.execute( select(ApprovalOrder) - .where(ApprovalOrder.status.in_(_PENDING_STATUSES)) + .where(ApprovalOrder.status.in_(statuses)) .order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc()) .limit(limit) ).scalars() ) -def count_pending_approvals(session: Session) -> int: +def count_pending_approvals(session: Session, *, statuses: tuple[int, ...]) -> int: + if not statuses: + return 0 return int( session.execute( select(func.count()) .select_from(ApprovalOrder) - .where(ApprovalOrder.status.in_(_PENDING_STATUSES)) + .where(ApprovalOrder.status.in_(statuses)) ).scalar_one() ) @@ -152,7 +167,11 @@ def count_unassigned_receipts(session: Session) -> int: # ==== 风险预警 ==== def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list: - """发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。""" + """发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。 + + 已终止业务(status=3)不再催收,排除在预警之外;业务行缺失时仍保留, + 避免历史脏数据让发货单从预警中静默消失。 + """ has_receipt = exists().where(Receipt.shipment_id == Shipment.id) return list( session.execute( @@ -161,7 +180,11 @@ def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> lis Business.business_no.label("business_no"), ) .outerjoin(Business, Business.id == Shipment.business_id) - .where(Shipment.ship_date <= cutoff, ~has_receipt) + .where( + Shipment.ship_date <= cutoff, + ~has_receipt, + or_(Business.status.is_(None), Business.status != 3), + ) .order_by(Shipment.ship_date.asc()) .limit(limit) ).all() @@ -174,7 +197,12 @@ def count_overdue_shipments(session: Session, *, cutoff: date) -> int: session.execute( select(func.count()) .select_from(Shipment) - .where(Shipment.ship_date <= cutoff, ~has_receipt) + .outerjoin(Business, Business.id == Shipment.business_id) + .where( + Shipment.ship_date <= cutoff, + ~has_receipt, + or_(Business.status.is_(None), Business.status != 3), + ) ).scalar_one() ) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py index bfad0d1..d52bc7b 100644 --- a/backend/app/schemas/dashboard.py +++ b/backend/app/schemas/dashboard.py @@ -98,6 +98,7 @@ class WarningBlock(BaseModel): receipt_overdue_days: int = 90 approval_timeout_days: int = 3 items: list[WarningItem] = Field(default_factory=list) + total: int = Field(default=0, description="全部规则命中总数(items 仅为展示上限内的子集)") receipt_overdue_total: int = 0 approval_timeout_total: int = 0 sync_available: bool = False 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/approval_service.py b/backend/app/services/approval_service.py index e0de787..e0cc6dd 100644 --- a/backend/app/services/approval_service.py +++ b/backend/app/services/approval_service.py @@ -19,6 +19,7 @@ """ import logging +from collections.abc import Iterable from datetime import date from sqlalchemy import func, select @@ -29,6 +30,7 @@ from app.core.constants import ( ACTION_REJECT, MODULE_APPROVAL, NO_PREFIX_APPROVAL, + ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, ) @@ -84,6 +86,18 @@ _NODE_ROLE_BY_STATUS = { _ROLE_LABEL = {ROLE_FINANCE: "财务", ROLE_LEADER: "领导"} +def pending_statuses_for_roles(role_codes: Iterable[str]) -> tuple[int, ...]: + """工作台「待我审批」:按当前用户角色映射在途审批状态。 + + admin 不代办审批,但作为管理视角可查看全部在途单(与审批管理列表 + 的可见性一致);财务/领导只看到本节点可操作的条目。 + """ + codes = set(role_codes) + if ROLE_ADMIN in codes: + return _PENDING_STATUSES + return tuple(status for status, role in _NODE_ROLE_BY_STATUS.items() if role in codes) + + def is_enabled(session: Session, order_type: int) -> bool: """读当日参数快照判定审批开关(一次提交路径内只读一次,调用方勿重读)。""" code = ENABLE_CODE_BY_TYPE.get(order_type) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 4f6ddef..e96fe89 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -18,8 +18,9 @@ from sqlalchemy.orm import Session from app.core.exceptions import BadRequestError from app.core.month import validate_month -from app.repositories import dashboard_repository, operation_log_repository +from app.repositories import dashboard_repository, operation_log_repository, user_repository from app.repositories.query_utils import LogFilters +from app.services import approval_service # 工作台只展示摘要,完整列表跳转对应管理页 _TODO_LIMIT = 5 @@ -30,14 +31,21 @@ _TREND_MONTHS = 6 _ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"} -def get_dashboard(session: Session, month: str | None = None) -> dict: - """四区块一次性出数(前端一次请求渲染整页)。""" +def get_dashboard(session: Session, month: str | None = None, *, current_user) -> dict: + """四区块一次性出数(前端一次请求渲染整页)。 + + 待办按当前用户角色只展示其可操作节点(admin 可查看全部在途单); + 风险预警仍是全局经营视角,不按人裁剪。 + """ resolved = _normalize_month(month) warning_params = _warning_params(session) + pending_statuses = approval_service.pending_statuses_for_roles( + user_repository.get_role_codes(current_user) + ) return { "month": resolved, "overview": _overview(session, resolved), - "todos": _todos(session), + "todos": _todos(session, pending_statuses), "warnings": _warnings(session, warning_params), "activities": _activities(session), } @@ -61,6 +69,11 @@ def _shift_month(month: str, delta: int) -> str: return f"{index // 12:04d}-{index % 12 + 1:02d}" +def _month_start(month: str) -> date: + """YYYY-MM → 该月首日(范围查询边界,避免函数列过滤)。""" + return date(int(month[:4]), int(month[5:7]), 1) + + def _trend_months(month: str) -> list[str]: """窗口为「当月及之前共 6 个月」,升序返回便于前端画图。""" return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)] @@ -72,7 +85,11 @@ def _overview(session: Session, month: str) -> dict: settlement = dashboard_repository.get_month_settlement(session, month) months = _trend_months(month) performance = dashboard_repository.performance_trend(session, months) - receipts = dashboard_repository.receipt_trend(session, months) + receipts = dashboard_repository.receipt_trend( + session, + start_date=_month_start(months[0]), + end_date=_month_start(_shift_month(months[-1], 1)), + ) return { "month": month, "performance_total": _dec(sums["performance_total"]), @@ -94,16 +111,27 @@ def _overview(session: Session, month: str) -> dict: } for item in months ], - "note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单", + "note": ( + "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月" + "(含未核算回款);不含经理 12.5%+5% 聚合分成与调整单" + ), } -def _todos(session: Session) -> dict: - """待办事项(FR-034,澄清 #11 起步范围)。""" - orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT) +def _todos(session: Session, pending_statuses: tuple[int, ...]) -> dict: + """待办事项(FR-034,澄清 #11 起步范围)。 + + ``pending_statuses`` 已按当前用户角色解析:财务只看待财务复核、 + 领导只看待领导审核;admin 查看全部在途单。 + """ + orders = dashboard_repository.list_pending_approvals( + session, statuses=pending_statuses, limit=_TODO_LIMIT + ) receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT) return { - "pending_approval_total": dashboard_repository.count_pending_approvals(session), + "pending_approval_total": dashboard_repository.count_pending_approvals( + session, statuses=pending_statuses + ), "pending_approvals": [ { "order_id": order.id, @@ -154,6 +182,7 @@ def _warnings(session: Session, params: dict) -> dict: "receipt_overdue_days": params["receipt_overdue_days"], "approval_timeout_days": params["approval_timeout_days"], "items": [], + "total": 0, "receipt_overdue_total": 0, "approval_timeout_total": 0, "sync_available": False, @@ -201,17 +230,21 @@ def _warnings(session: Session, params: dict) -> dict: } for order in orders ] + receipt_overdue_total = dashboard_repository.count_overdue_shipments( + session, cutoff=receipt_cutoff + ) + approval_timeout_total = dashboard_repository.count_overdue_approvals( + session, cutoff=approval_cutoff + ) return { "enabled": True, "receipt_overdue_days": params["receipt_overdue_days"], "approval_timeout_days": params["approval_timeout_days"], "items": items, - "receipt_overdue_total": dashboard_repository.count_overdue_shipments( - session, cutoff=receipt_cutoff - ), - "approval_timeout_total": dashboard_repository.count_overdue_approvals( - session, cutoff=approval_cutoff - ), + # items 有展示上限(_WARNING_LIMIT),徽标计数必须用全量命中数 + "total": receipt_overdue_total + approval_timeout_total, + "receipt_overdue_total": receipt_overdue_total, + "approval_timeout_total": approval_timeout_total, "sync_available": False, } 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/README.md b/backend/tests/README.md index f4513d9..b802319 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -140,22 +140,22 @@ uv run pytest tests/integration -q # 仅集成 | 文件 | 守什么 | 用例数 | | --- | --- | --- | -| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 24 | -| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、全公司分成只出现一次、应发合计取对账口径、比例标题参数化** | 16 | +| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、分成按参数、无成员经理出 0 值行;**无台账经理挂上级不 500、仅有调整单人员并入团队/未分配、经理个人户开关两侧口径、按经理筛选不误判其他团队下属、对账恒等式**;**应发与月结 `total_payable` 对拍(两位经理各计一次 5%)、有调整单时等于月结+调整净额、筛选态只汇总展示团队**;导出页眉 ID→名称/中文标签、回款维度 sheet 透传筛选、**导出超限与取数异常的失败审计**、非法月份查询/导出均 400;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**;业务统计排除经理聚合行;普通用户 403 | 27 | +| `tests/unit/test_report_helpers.py` | 区间分桶边界与 8 档编码唯一;标签覆盖全部枚举;Excel 模板页眉/签字栏/空数据/金额格式/合计/公式注入/维度 sheet/状态中文;**部门汇总签字稿五块顺序、零成员团队与未分配行、5% 按计入次数累计、应发合计取对账口径、聚合行舍入差单列、比例标题参数化** | 17 | | `tests/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 | -运行:`uv run pytest tests/integration/test_reports.py -q` → **24 passed**(2026-10-19 修复回归);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **33 passed**;全量 **847 passed**(2026-10-19)。 +运行:`uv run pytest tests/integration/test_reports.py -q` → **27 passed**(2026-10-20 复核);`uv run pytest tests/unit/test_report_helpers.py tests/unit/test_month_validation.py -q` → **34 passed**;全量 **862 passed**(2026-10-20,11:32)。 -**e2e**:`frontend/e2e/report_flow.py` 已补回款多桶同时展开、部门下钻筛选生效、打印截断提示、部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 与前后端在跑。 +**e2e 实跑(2026-10-20)**:`frontend/e2e/report_flow.py` 12/12 通过。回款统计改为展开目标桶后按**本次业务编号**定位下钻行(不依赖桶累计值,可重复运行);另覆盖多桶同时展开、部门下钻筛选、打印截断提示、部门汇总打印五块结构。 ### 工作台(M4 ⑧,2026-10-19 完成) | 文件 | 守什么 | 用例数 | | --- | --- | --- | -| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志 | 5 | +| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志;**待办按角色过滤(财务/领导/多角色/admin)+ 真实流转;回款逾期命中与阈值参数生效;终止业务排除;`warnings.total` 不受展示上限截断;回款趋势跨月归属** | 11 | | `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 | -运行:`uv run pytest tests/integration/test_dashboard.py -q` → **5 passed**(2026-10-19)。 +运行:`uv run pytest tests/integration/test_dashboard.py -q` → **11 passed**(2026-10-19 code-review 修复回归)。 **实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。 @@ -181,6 +181,9 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-19 | 拆行收口批次 | 全量 788 | ✅ 788 passed(9:20) | **2026-10-19 业务答复落实完成**:#14 首日取整 10‰(公式段/锚点不变,用例反转);#12 单笔侧开关消费(1=业务员递减计提);**二次分摊拆行**(参与人份额+负责人余额守恒,uk 三列版);权限分层(params 查看放开/写仅 admin);V8 迁移(审批开关默认开/撤试用期参数/增基数开关)。定点:calc 20 / settlements 13(含 #12 开关两档金标准 6.84→24.34)/ businesses 30(保存侧+3)/ formula 35。e2e calc_settlement_flow 交接并行会话 | | 2026-10-19 | **报表统计修复回归** | 全量 847 | ✅ 847 passed(10:13) | **M4 报表 code-review 15 项修复后全量回归**(后端 8 + 前端 7):归属三集合/调整单全量并入/经理个人户拆分/对账出参、分块签字稿导出、导出审计覆盖取数、筛选页眉 ID→名称、月份校验 400、岗位只读选项。报表集成 24 / 报表单元 16 / 月份校验 17。type-check + build 绿;e2e `report_flow.py` 扩至 12 步,待 `E2E_ADMIN_PASSWORD` 实跑 | | 2026-10-19 | 全部 | 852 | ✅ 852 passed(9:17) | **M4 ⑧ 工作台完成**(四区块实时聚合 + ECharts 5.6 前端 + V10 预警参数三项;新增 5 例;参数注册表 19→22;同步模块显式降级)。期间修复:alembic `--sql` 导出 GBK 导致 schema 同步误报 | +| 2026-10-19 | **code-review 修复回归** | 全量 862 | ✅ 862 passed(9:36) | **M4 工作台 + 报表 3 必须 + 8 建议修复**:普通用户落地页/守卫(报表集成 27、工作台集成 11、报表单元 17,新增 10 例);部门汇总 5% 按计入次数累计并与月结 `total_payable` 对拍、聚合行舍入差;工作台待办按角色过滤、逾期命中与阈值生效、终止业务排除、`warnings.total` 截断、趋势跨月归属。ruff 绿;type-check + build 绿 | +| 2026-10-20 | e2e 全链路 | — | ✅ login 8 / system 13 / role_home 4 / report 12 / calc_settlement 8 | 重启后端加载新代码后实跑:普通用户落地页与受限页回退、报表四页/导出/打印、核算与月结链路全过。`report_flow` 回款统计改为展开目标桶后按本次业务编号定位明细,不依赖桶累计值,可重复运行 | +| 2026-10-20 | 全量回归 | 862 | ✅ 862 passed(11:32) | **工作台/报表修复提交后的全量复核**;e2e 三脚本复跑:role_home 4/4、report 12/12、calc_settlement 8/8;calc 审批开关已还原为 1,无 `e2e_role_*` 残留 | | 2026-10-19 | 单元全量 + 报表定点 | 484 | ✅ 484 passed(45.94s);报表单元 11 例 0.70s | **M4 报表模块收尾**(前端四页/打印视图/导出与单元测试补齐)。ruff 绿;前端 `pnpm type-check` 绿。报表集成 7 例记录见上节 | | 2026-10-19 | 全部 | 806 | ✅ 806 passed(7:59) | **M4 ⑤ 报表统计全量回归**(报表新增 18 例:集成 7 + 单元 11;另含并行会话并入的 M3 前端修复与字体合规)。ruff 绿。e2e:`calc_settlement_flow` 8 步由并行会话跑通;`report_flow.py` 待 `E2E_ADMIN_PASSWORD` 运行 | @@ -195,6 +198,9 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-19 | 导出取数与超限校验在审计上下文之外 | 超限 400 与取数异常**不留失败日志**,与 M4 计划 T12「导出 400/失败审计」不符 | `_export_payload` 移入 `with audit`;静态标题映射(AuditRecord frozen) | | 2026-10-19 | 月份只校验长度 | `2026-13` 传到 `calendar.monthrange` 抛 ValueError → **500**;导出手工构造模型抛裸 ValidationError 同样 500 | 新增 `core/month.py`(格式+01-12+年份);四类 query、月结、调整单接入;导出 ValidationError 转 400 | | 2026-10-19 | 前端报表五处状态缺陷 | 打印超限判定恒假(静默截断)、部门下钻 query 未读取、回款多桶共用一个 drill 状态(面板串行)、summary 在请求序号守卫前写入(合计串号)、e2e 全页行数选择器必失败 | 分别修正判定/读取 query/按桶独立状态+数组参数/`onData` 回调/限定主桶表;补 e2e 多桶展开、下钻、截断、部门打印用例 | +| 2026-10-19 | 工作台权限收紧后守卫仍回退 `/dashboard` | 普通用户登录/直连受限页被反复重定向到同一受限页 → **无法进入系统**(导航被判定无限重定向) | `router/access.ts` 统一准入与落地页解析(工作台 → 业务数据 → 回款);登录回跳经 `canAccessPath` 校验;菜单/面包屑复用同一判定 | +| 2026-10-19 | 部门汇总应发只计一次全公司 5% | 月结每位有聚合行的经理各计一次 5%,签字稿少计 N-1 份;**导出/打印金额小于月结应发** | 团队行增 `has_aggregate/aggregate_amount`(优先冻结聚合行);对账改按聚合额求和,新增「聚合行舍入差」吸收分列 q2 尾差;补「与 `total_payable` 对拍」用例 | +| 2026-10-19 | 工作台待办未按角色过滤 | 财务看得到「待领导审核」、领导看得到「待财务复核」,与 FR-034「仅看到与自己职责相关」不符 | `pending_statuses_for_roles` 按角色映射在途节点;repository 参数化 statuses;admin 保留全部在途视角 | | 2026-10-09 | 日志脱敏在 `logging.Filter` 阶段改写 `record.msg` | 抹掉 `%s` 占位符 → logging 内部 `msg % args` 抛 TypeError → **整条日志丢失**(比不脱敏更危险) | 改为 `SensitiveFormatter` 在格式化**之后**脱敏;补回归用例 | | 2026-10-09 | `PasswordHash.recommended()` 在 pwdlib 0.3.x 推荐 Argon2 而非 BCrypt | 密码算法与规范不符,且随库版本变化——同一批密码可能混用两种算法 | 改为显式 `PasswordHash((BcryptHasher(),))` 钉死算法 | | 2026-10-09 | 模型未设置表级 `comment`,`update_time` 用 `server_onupdate` | 违反规范红线;`server_onupdate` 只是 ORM 标记,**不生成 `ON UPDATE CURRENT_TIMESTAMP` 子句**,更新时间永不刷新 | 补表注释;改用 `text("CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP")` | @@ -248,7 +254,7 @@ uv run pytest tests/integration -q # 仅集成 | 基础数据管理 | ✅ 已完成 | 2026-10-13 完成(48 例,见上表);「被业务数据引用」的删除保护已随 T11 的业务/发货禁删用例覆盖 | | 业务数据管理 | ✅ 后端已完成 | 2026-10-13 完成(70 例,见上表);前端页面与绩效核算联动(M3 台账核销回写 settlement_status)待 M3 补测 | | M3 增量(设置/分配/审批) | ✅ 已完成 | 2026-10-16 完成(44 例,见上表);审批流的**月结审批对象**(order_type=2)随 ④ 月结子块接入时补测 | -| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例。**待补**:e2e `calc_settlement_flow`(台账/月结两页真实浏览器链路);业务答复回填批次(88 会话:首日 10‰/聚合基数开关/二次分摊拆行)落地后按同粒度补测 | -| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;e2e 用例已写好,需完整环境用 `E2E_ADMIN_PASSWORD` 实跑 | +| 绩效工资核算(M3-core) | ✅ 已完成 | 2026-10-17 完成(核算/月结/调整单,见运行记录);2026-10-19 code-review 21 项修复并补回归约 20 例;2026-10-20 e2e `calc_settlement_flow` 8/8 实跑通过(台账/月结真实浏览器链路) | +| 报表统计 | ✅ 已完成 | 2026-10-19 完成并补齐回归;2026-10-20 e2e `report_flow` 12/12 实跑通过,全量 862 passed | > 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 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_dashboard.py b/backend/tests/integration/test_dashboard.py index 6a5c5a3..67e6d47 100644 --- a/backend/tests/integration/test_dashboard.py +++ b/backend/tests/integration/test_dashboard.py @@ -191,7 +191,7 @@ def test_预警_审批超时命中且参数关闭后不产生(client: TestClient client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]) # 把待审批单提交时间改到 4 天前(阈值默认 3 天) - from sqlalchemy import select, update + from sqlalchemy import update from app.core.db import get_session_factory from app.models import ApprovalOrder @@ -230,3 +230,224 @@ def test_动态_来源操作日志(client: TestClient, admin: dict): data = _dashboard(client, admin["headers"]) assert data["activities"], "工作台动态为空:写操作应产生操作日志" assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0]) + + +# ==== 角色待办 / 预警阈值 / 趋势(2026-10-19 code-review 修复回归)==== + + +@pytest.fixture +def finance(make_user, auth_headers) -> dict: + user = make_user(["finance"]) + user["headers"] = auth_headers(user) + return user + + +@pytest.fixture +def leader(make_user, auth_headers) -> dict: + user = make_user(["leader"]) + user["headers"] = auth_headers(user) + return user + + +def _insert_approval(status: int) -> None: + """直插在途审批单(避免依赖两次流转,专注工作台过滤口径)。""" + from app.core.db import get_session_factory + from app.models import ApprovalOrder + + factory = get_session_factory() + with factory() as session: + session.add( + ApprovalOrder( + order_no=f"AP{uuid.uuid4().hex[:12]}", + order_type=2, + ref_id=0, + refer_no="t_工作台", + status=status, + submitter_name="t_admin", + ) + ) + session.commit() + + +def _set_param(client: TestClient, headers: dict, code: str, value: str) -> None: + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "remark": f"t_工作台:{code}", + "items": [{"param_code": code, "dimension_value": "", "value": value}], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + + +def _shipment_scene(client: TestClient, headers: dict, *, days_ago: int) -> tuple[dict, dict]: + """造一笔无回款的发货(用于回款逾期预警)。""" + from tests.integration.factories import business_item, business_payload, customer + + person = _person(client, headers) + cust = customer(client, headers) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + owner_personnel_id=person["id"], + items=[business_item(cost_amount="3000.00")], + ), + headers=headers, + ) + assert resp.status_code == 200, resp.text + business = resp.json()["data"] + ship_date = (date.today() - timedelta(days=days_ago)).isoformat() + resp = client.post( + _SHIPMENTS, + json={ + "business_id": business["id"], + "ship_date": ship_date, + "items": [{"product_name": "t_工作台逾期", "quantity": "1", "sale_price": "1.00"}], + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return business, resp.json()["data"] + + +def test_待办_按角色仅显示可操作节点( + client: TestClient, admin: dict, finance: dict, leader: dict, make_user, auth_headers +): + """财务只看到待财务复核、领导只看到待领导审核;admin 与多角色看并集。""" + _insert_approval(1) + _insert_approval(2) + + assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1 + assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1 + assert _dashboard(client, admin["headers"])["todos"]["pending_approval_total"] == 2 + + both = make_user(["finance", "leader"]) + both["headers"] = auth_headers(both) + assert _dashboard(client, both["headers"])["todos"]["pending_approval_total"] == 2 + + finance_orders = { + item["order_no"] for item in _dashboard(client, finance["headers"])["todos"]["pending_approvals"] + } + leader_orders = { + item["order_no"] for item in _dashboard(client, leader["headers"])["todos"]["pending_approvals"] + } + assert finance_orders.isdisjoint(leader_orders) + + +def test_待办_财务通过后领导出现待办( + client: TestClient, admin: dict, finance: dict, leader: dict +): + """真实流转:财务节点通过后,条目从财务待办消失并进入领导待办。""" + _chain(client, admin["headers"]) + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + settlement_id = resp.json()["data"]["id"] + resp = client.post( + f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"] + ) + assert resp.status_code == 200, resp.text + + order = _dashboard(client, admin["headers"])["todos"]["pending_approvals"][0] + assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 1 + assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 0 + + resp = client.post( + f"/api/v1/system/approvals/{order['order_id']}/action", + json={"action": 1}, + headers=finance["headers"], + ) + assert resp.status_code == 200, resp.text + assert _dashboard(client, finance["headers"])["todos"]["pending_approval_total"] == 0 + assert _dashboard(client, leader["headers"])["todos"]["pending_approval_total"] == 1 + + +def test_预警_回款逾期命中且阈值参数生效(client: TestClient, admin: dict): + """发货超阈值且无任何回款 → 命中;调大阈值后不再命中。""" + _, shipment = _shipment_scene(client, admin["headers"], days_ago=120) + + data = _dashboard(client, admin["headers"]) + assert any( + item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"] + for item in data["warnings"]["items"] + ), "120 天未回款的发货批次未命中预警" + + _set_param(client, admin["headers"], "warning.receipt.overdue.days", "200") + data = _dashboard(client, admin["headers"]) + assert not any( + item["code"] == "receipt_overdue" and item["object_id"] == shipment["id"] + for item in data["warnings"]["items"] + ), "阈值调大后仍命中,说明参数未生效" + + +def test_预警_终止业务不再产生回款逾期(client: TestClient, admin: dict): + """已终止业务不再催收,其未回款发货单不得留在预警列表。""" + business, shipment = _shipment_scene(client, admin["headers"], days_ago=120) + resp = client.put( + f"{_BUSINESSES}/{business['id']}/status", + json={"status": 3, "reason": "t_工作台终止"}, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + + data = _dashboard(client, admin["headers"]) + assert not any(item["object_id"] == shipment["id"] for item in data["warnings"]["items"]) + assert data["warnings"]["receipt_overdue_total"] == 0 + + +def test_预警_总数不受展示上限截断(client: TestClient, admin: dict, monkeypatch): + """items 只展示前 N 条,徽标 total 必须仍是全量命中数。""" + from app.services import dashboard_service + + _shipment_scene(client, admin["headers"], days_ago=120) + _shipment_scene(client, admin["headers"], days_ago=120) + monkeypatch.setattr(dashboard_service, "_WARNING_LIMIT", 1) + + data = _dashboard(client, admin["headers"]) + assert data["warnings"]["total"] == 2 + assert len(data["warnings"]["items"]) == 1 + assert data["warnings"]["total"] == data["warnings"]["receipt_overdue_total"] + + +def test_总览_回款趋势按到账月跨月归属(client: TestClient, admin: dict): + """趋势窗口按 receipt_date 范围过滤,月初/月末不得串月。""" + from tests.integration.factories import business_item, business_payload, customer + + person = _person(client, admin["headers"]) + cust = customer(client, admin["headers"]) + resp = client.post( + _BUSINESSES, + json=business_payload( + cust["id"], + owner_personnel_id=person["id"], + items=[business_item(cost_amount="3000.00")], + ), + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + business = resp.json()["data"] + + first_of_month = date.today().replace(day=1) + previous_last = first_of_month - timedelta(days=1) + for receipt_date in (previous_last, first_of_month): + resp = client.post( + _RECEIPTS, + json={ + "business_id": business["id"], + "receipt_date": receipt_date.isoformat(), + "amount": "1000.00", + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + + trend = { + item["month"]: Decimal(str(item["receipt_total"] or 0)) + for item in _dashboard(client, admin["headers"])["overview"]["trend"] + } + assert trend[previous_last.strftime("%Y-%m")] == Decimal("1000.00") + assert trend[first_of_month.strftime("%Y-%m")] == Decimal("1000.00") 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/docs/superpowers/plans/2026-10-12-剩余开发计划.md b/docs/superpowers/plans/2026-10-12-剩余开发计划.md index 96683ec..0a70f09 100644 --- a/docs/superpowers/plans/2026-10-12-剩余开发计划.md +++ b/docs/superpowers/plans/2026-10-12-剩余开发计划.md @@ -45,12 +45,12 @@ ② 基础数据管理 ✅ ← 人员引用 ① 岗位角色(外键上游) ③ 业务数据管理 ✅ ← 引用 ② 人员/产品/客户 │ 增量(2026-10-15):二次分配方案(合同刚建立时录入) -④ 绩效工资核算 ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据 -│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(语义待澄清) -⑦ 复核审批流(新) ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展 -⑤ 报表统计(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约 -⑧ 工作台(新) ← 消费 ④⑤⑥⑦ 数据的聚合展示层 -⑨ 备份恢复与运维检查(新) ← 独立于业务块,可与任一批次并行,建议尽早 +④ 绩效工资核算 ✅ ← 消费 ① 参数快照(按回款到账日)+ ②③ 数据 +│ 增量(2026-10-15):月度结算/调整单/负数工资;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) +⑦ 复核审批流(新)✅ ← 月结/二次分配的审批链(营销后勤→财务→领导);登录角色体系扩展 +⑤ 报表统计 ✅(∥ ⑥ T+ 同步) ← ⑤ 只读消费 ③④;签字导出模板依赖 ⑦ 与公司信息;⑥ 受 IT 字段映射制约(待 IT 18 项答复) +⑧ 工作台(新)✅ ← 消费 ④⑤⑥⑦ 数据的聚合展示层 +⑨ 备份恢复与运维检查(新)✅ ← 独立于业务块,可与任一批次并行,建议尽早 ``` ### 1.2 顺序论证(要点) @@ -68,12 +68,12 @@ | ① 系统设置与参数体系 | 岗位角色 CRUD + 模板绑定;参数目录/版本化存储/按日期取值/变更留痕/对比/导出;公式纯函数;计算预览。**✅ 已完成**。增量(2026-10-15):公司信息配置(公司名称、logo 等基本信息);审批开关配置走参数体系;新增参数一律进参数注册表(用户重申:参数配置尽量灵活) | (原验收已达成)增量:公司信息可配置并用于登录页与导出页眉;审批开关参数化,关闭直接生效、开启走审批 | | ② 基础数据管理 | 人员(岗位/工资标准/团队/辐射区域/拓展计划)、产品类型与目录、业务(客户)与归属变更。**✅ 已完成** | 三类数据可维护并留痕;人员岗位引用 ① 岗位表;表含 T+ 预留列;客户分类(个人户/经理个人户/企业户)与归属修改不回溯历史 | | ③ 业务数据管理 | 业务明细+指导价、发货(批次/运费/发货日)、回款(批次号/到账日/以物抵顶/手动指定发货批次)、毛利润(不含运费)、客户评级计算器。**✅ 已完成**。增量(2026-10-15):二次分配方案 —— 只出现在合同刚建立时;合同建立时可手动选择参与二次分配的业务员;分配比例不要求合计 100%;审批开关开启时走审批 | (原验收已达成)增量:合同建立时可录入参与业务员与比例(不强制合计 100%);开关关闭直接生效;方案修改不影响已结算月份 | -| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(语义待澄清,见 3.3) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 | -| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计 | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 | -| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发 | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 | -| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions) | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 | -| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态 | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 | -| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本 | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 | +| ④ 绩效工资核算 | 核算引擎(按回款到账日取参数)、单笔台账+核销防重、经理 12.5%+5%、试用期/年累计 18000、起付 60% 与 R×R、预付款/以物抵顶、月工资(分岗口径)、重算授权留痕。增量(2026-10-15):月度结算(按确认回款和计算快照生成);支持负数工资、扣补说明和调整单;结算后锁定 —— 修改源数据不影响已结算结果,只产生提示、历史记录和后续调整;二次分配分摊(#1–#3 答复空白,开发侧暂定口径已实现,见 3.3.1) | 任一笔回款可复算到分(零误差);同笔回款不重复核算;08-13 前后企业户 2‰/3‰ 回溯正确;月工资表可生成。增量:任一月份可出月结快照并锁定;已锁月份源数据变更只产生提示与历史,金额不变;调整单留痕可追溯 | +| ⑤ 报表统计 | 个人绩效明细、部门汇总、业务统计、回款区间统计、Excel 导出与打印。增量(2026-10-15):导出模板围绕财务复核和领导签字设计。**✅ 已完成(2026-10-19)** | 四表口径与台账一致且可下钻单笔;导出精度与页面一致。增量:导出模板含复核/签字栏与公司信息页眉 | +| ⑥ T+ 数据同步 | 五类数据抓取/清洗/入库、唯一键去重、失败重试、同步日志、周定时+手动触发。**⏳ 阻塞(IT 18 项未答复)** | 手动同步幂等(重跑不重复入库);失败有日志与重试;不覆盖人工字段 | +| ⑦ 复核审批流(新) | 月结与二次分配的审批链:营销后勤提交 → 财务复核 → 领导审核;审批开关配置(参数化,关闭直接生效);审批意见与版本;登录角色体系扩展(营销后勤/财务/领导为系统新登录角色,激活闲置的 permissions/role_permissions)。**✅ 已完成(2026-10-19;权限细目待业务清单)** | 开关关闭时提交即生效;开启时三级流转全程留痕;每级意见与版本可追溯;重算/改单产生新版本,不覆盖既有审批记录 | +| ⑧ 工作台(新) | 经营绩效总览、待办事项、风险预警、最近同步/计算/审批动态。**✅ 已完成(2026-10-19)** | 四区块出数口径与台账/审批/同步日志一致可下钻;预警规则参数化可配置 | +| ⑨ 备份恢复与运维检查(新) | MySQL 定时备份与恢复演练、运维检查(容器/磁盘/日志/Redis/同步状态)、检查清单与脚本。**✅ 已完成(2026-10-16)** | 任一备份可按文档恢复成功;检查项脚本化输出健康报告 | ### 1.4 里程碑(交付批次) @@ -81,9 +81,9 @@ | --- | --- | --- | | **M1** | ① 系统设置与参数体系 | ✅ 已完成(2026-10-13 推送 feat/system-params) | | **M2** | ② + ③ | ✅ 已完成(2026-10-13 分支 feat/base-business-data) | -| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流 | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」;开工前必须澄清:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7);内部建议顺序 ①增量 → ③增量 → ⑦ 审批框架 → ④ 核算 → 月结与审批串联 | -| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台 | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 | -| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付) | 数据持续积累,备份先行;不依赖业务模块进度 | +| **M3** | ① 增量(公司信息/审批开关)+ ③ 增量(二次分配录入)+ ④ 绩效工资核算(含月结/调整单/负数工资)+ ⑦ 复核审批流。**✅ 已完成(2026-10-19,feat/m3-increments)** | 核心闭环「核算 → 提交 → 财务复核 → 领导审核 → 锁定」已交付;余 3.3-1 答复与审批权限细目。开工前澄清项:二次分配分摊语义(3.3-1)、经理个人户基数(开放问题 #7,已参数化) | +| **M4** | ⑤ 报表(含财务复核/领导签字导出模板)+ ⑥ T+ 同步(并行)+ ⑧ 工作台。**✅ ⑤⑧ 已完成(2026-10-19);⑥ 待 IT 18 项答复** | ⑥ 受 IT 制约,谁先就绪谁先交;⑧ 消费 ④⑤⑥⑦ 出数,排其后 | +| **⑨ 备份恢复与运维检查** | 不占独立批次,随任一批次并行(建议 M3 期间并行交付)。**✅ 已完成(2026-10-16)** | 数据持续积累,备份先行;不依赖业务模块进度 | ### 1.5 需求依据对照 @@ -95,7 +95,7 @@ | ④ | 需规 2.3.1–2.3.3;对照表 #3/#4/#6/#7/#11/#15/#16/#17/#20–#24;增量见 3.1(一)(四) | 协助 25%;经理 8‰;26 天示例;含运费公式 | | ⑤ | 需规 2.4.1–2.4.3;对照表 #22(明细展示 R);增量见 3.1(四) | — | | ⑥ | 需规各「同步 T+」条;对照表 #10/#26/#27 | — | -| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——尚未回填需规与对照表,引用以第三章原文为准 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制,关系待业务澄清(3.3-1) | +| ⑦⑧⑨ 及各块增量 | **2026-10-15 功能清单核查补充(本文第三章,原文照录)**——已随 SRS V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定 | 二次分配 ≠ 协助 25%:#15 作废的是「协助 25% 分成」条款,二次分配是合同建立时的新机制;#1–#3 答复仍空白,开发侧暂定口径已实现(3.3.1) | --- @@ -610,11 +610,13 @@ git commit -m "test(system): 参数体系模块用例与测试记录" | 6 | 导出格式与列集合(是否含批次说明/操作人) | 以需规 2.3.1(四) 字段为准,导出前与业务对一次列 | | 7 | 经理个人户业绩如何进入 12.5%+5%(裁决 #7 的具体衔接:该业绩是否计入「本人带领业务员」基数) | ④ 开工前必须确认(影响台账归属字段设计) | +> **2026-10-20 回填**:上述开放问题均已有结论,原表保留为 2026-10-12 设计时快照。#1 → 澄清 #14(首日整 10‰,A 口径);#2 → #18(按 2‰ 回溯,双种子保留);#3 → #15(一期仅 FULL,BALANCE 留参数位);#4 → #16(撤 `probation.salary.basic`,读 `personnel.salary_standard`);#5 → #17(查看放开登录用户、修改仅管理员);#6 → 催办清单三、R3;#7 → #12(`manager.personal.included` 参数,默认 0=不计入)。 + --- ## 第三章 2026-10-15 功能清单核查补充(新增需求) -> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容尚未回填需规文档与《问题与解决方案对照表》,是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。 +> **来源与地位:** 用户以现有功能清单核查后补充的需求(2026-10-15)。本章 3.1 为**原文照录**(未做任何改写),3.2 起为开发侧归属与推演,**两者严格区分**;引用新需求一律以 3.1 原文为准。本批内容已随《需求规格说明书》V2.1(2026-10-19)回填;《问题与解决方案对照表》是否回填由用户决定。任务级实施计划在对应里程碑开工前按第二章同粒度补齐。 ### 3.1 需求原文(照录) @@ -656,6 +658,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录" #### (六)系统设置 - 公司信息配置:公司名称、logo 等基本信息 +- **2026-10-20 回填**:参数注册表已扩至 22 项(V8/V10 增补审批开关、试用期迁移、预警阈值等) #### (七)参数配置强调 @@ -666,7 +669,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录" #### (一) 二次分配方案 → ③ 录入 + ④ 分摊消费 + ⑦ 审批联动 + ④ 调整单衔接 - 「只出现在合同刚建立时」→ 录入入口放业务创建流程(与 `businesses` 创建同事务或紧随其后);合同建立后是否允许补录/修改未明说,方案修改的留痕方式待设计; -- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1),存储上只存各参与人比例、不强制校验合计; +- 「分配比例不要求合计 100%」→ 余额归属规则需求未说明(3.3-1);存储上只存各参与人比例、不强制校验合计。**2026-10-20 回填**:#1–#3 答复仍空白,开发侧已按暂定口径实现分摊拆行(余额归负责人、行合计守恒,见 3.3.1),答复后如有变更走口径调整; - 「修改不影响已结算月份」→ 月结快照天然隔离(快照固化当月结果),方案表需支持编辑 + 修改留痕; - 「已结算月份调整只能通过调整单或后续月份扣补」→ 调整单为统一承载机制,见 (四)。 @@ -676,7 +679,7 @@ git commit -m "test(system): 参数体系模块用例与测试记录" | --- | --- | | 经营绩效总览 | ④ 台账/月结(按人/团队/月份汇总) | | 待办事项 | ⑦ 待审批单、⑥ 同步失败、回款未指定批次(M2 已有该状态可复用) | -| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器 | +| 风险预警 | 规则未定义(3.3-5),先落参数化规则容器。**2026-10-20 回填**:方向已答复参数化(#10),首批三条按默认落 V10 参数(逾期 90 天/审批超时 3 天/同步失败待 ⑥) | | 最近同步/计算/审批动态 | ⑥ 同步日志 + ④ 核算/月结记录 + ⑦ 审批记录 | #### (三) 复核审批 → ⑦ 新模块 @@ -687,9 +690,11 @@ git commit -m "test(system): 参数体系模块用例与测试记录" - 「审批意见与版本」→ 审批单带版本号:重算/改单产生新版本,旧版本与意见留痕不覆盖; - **登录角色体系扩展**:营销后勤/财务/领导是系统新登录角色(`roles` 表基础设施已有;`permissions`/`role_permissions` 自 M1 起闲置待权限清单,此处激活)——打破一期「营销人员不登录」的前提,但与该原则不矛盾(营销人员仍不登录,后勤/财务/领导是职能角色)。 +> **2026-10-20 回填**:审批框架与月结审批对象已交付(2026-10-19);权限点清单仍待业务方给出(澄清 #6,催办 B-6),当前按 role_code 粗粒度运行,营销后勤暂不可见审批列表。 + #### (四) 月结、审批、报表 → ④ 月结子块 + ⑦ 审批 + ⑤ 导出模板 -- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」定义待澄清(3.3-3); +- 月度结算 = 台账之上的月度汇总快照(按月生成、含状态机:草稿/待审/已审定/已锁定);「确认回款」已答复为人工确认、可手动关闭(3.3-3),月结按已确认回款取数; - 「支持负数工资」与既有裁决「负绩效按公式正常计算(不归零)」一致,无需改公式口径; - 调整单:新单据类型(补发/扣减方向、关联月份、扣补说明、审批状态); - 「结算后锁定」:已锁月份的快照金额不可变;源数据修改只产生提示、历史记录和后续调整 —— 与 #19「归属变更不回溯历史」同一哲学,扩展到全部源数据; @@ -708,6 +713,8 @@ git commit -m "test(system): 参数体系模块用例与测试记录" ### 3.3 待澄清问题(不阻塞收录,对应模块开工前须澄清) +> **回填状态(2026-10-20):** 下表保留 2026-10-15 原始问题与建议默认(历史快照);业务答复与落实情况见 3.3.1。3.3-1 仍无业务答复,开发侧已按暂定口径实现分摊拆行,答复后如有变更按口径调整。 + | # | 事项 | 影响模块 | 建议默认处理 | | --- | --- | --- | --- | | 1 | 二次分配的**分配标的**(绩效金额?折算毛利润?业绩额?)与**余额归属**(合计 ≠100% 时差额归谁);与已作废的「协助 25%」(#15)是何关系 | ④ 分摊逻辑与台账归属字段 | 未确认前 ④ 只落录入与存储,不实现分摊 | @@ -718,27 +725,41 @@ git commit -m "test(system): 参数体系模块用例与测试记录" | 6 | 调整单是否走同一审批链;能否跨月调整已锁定月份 | ④⑦ | 建议走同一审批链;「后续月份扣补」为默认路径,跨月调整需授权留痕 —— 待确认 | | 7 | 待办事项的范围(除审批/同步失败外还含哪些,回款未指定批次是否算待办) | ⑧ | 按 3.2(二) 默认清单起步,业务确认后扩充 | -### 3.3.1 答复回填(2026-10-19) +### 3.3.1 答复回填(2026-10-19,2026-10-20 修订) -> 业务方 24 项答复已到(2026-10-16,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列),2026-10-19 全部落实并实现;本节为 3.3 各项的落定状态,供后续引用。答复与默认不同处以答复为准;仍未答复项见 `docs/待答复催办清单_2026-10-19.md`。 +> **修订说明(2026-10-20):** 2026-10-19 版本把 24 项统一写成「全部落实并实现」,与 3.3-1 答复空白、开发侧暂定分摊的事实不符。本次按「业务答复 / 落实状态 / 关联项」重写;3.3-1 明确标注为开发侧暂定口径,不视为业务确认。 +> +> 业务方 24 项答复于 2026-10-16 到达(回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列)。分组:确认按建议默认 13 项(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认按不计入);与默认不同、已按答复落实 4 项(#5/#14/#16/#19);部分答复 3 项(#4/#6/#12);仍开放 4 项(#1/#2/#3/#20)。未答复项对外跟催见 `docs/待答复催办清单_2026-10-19.md`。 -| 3.3# | 事项 | 落定状态 | -| --- | --- | --- | -| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 关系) | **答复仍空白;2026-10-19 已按建议默认实现分摊拆行**——分配标的=绩效金额份额(参与人份额×比例、负责人拿余额、行合计守恒、合计>100% 与负责人自分 400 拦截);协助 25% 维持仅记录不核算;业务答复后如有变更走口径调整 | -| 2 | 二次分配审批开关默认值 | 已答复:**默认开启**,可手动关闭(已落实 V8 参数种子) | -| 3 | 「确认回款」定义 | 已答复:需人工确认动作,可手动关闭(系统已按此预留并实现) | -| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认;现按最小授权运行(后勤=提交录入,财务=复核+导出,领导=审核只读),营销后勤暂不可见审批列表 | -| 5 | 风险预警规则 | 已答复:方向确认参数化;首批三条(逾期 90 天/同步失败/超时 3 天)未点名,按默认起步——⑧ 工作台实现 | -| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 已答复:同一审批链;跨月调整需管理员授权并留痕(已实现) | -| 7 | 待办事项范围 | 已答复:待审批 + 同步失败 + 回款未指定批次起步——⑧ 工作台实现 | +| 3.3# | 事项 | 业务答复(2026-10-16) | 落实状态(2026-10-20) | 关联项 / 证据 | +| --- | --- | --- | --- | --- | +| 1 | 二次分配标的与余额归属(含与 #15 协助 25% 的关系) | **未答复**(澄清 #1–#3 空白) | **开发侧暂定口径已实现**(非业务答复):分配标的=绩效金额份额;参与人=份额×比例,负责人=全额−已分累计(余额归负责人);行合计守恒;合计>100% 与负责人自分 400 拦截;重算作废全部拆行。协助 25% 维持仅记录不核算。**答复后校准** | 澄清 #1/#2/#3;催办一、A-1 至 A-3;实现 ccfd206 | +| 2 | 二次分配审批开关默认值 | 默认开启,可手动关闭 | 已落实:V8 参数种子默认开启;关闭时提交即生效(自动通过留痕) | 澄清 #5;`approval.secondary_allocation.enabled` | +| 3 | 「确认回款」定义 | 需人工确认动作,可手动关闭 | 已落实:月结按已确认回款取数;回款确认状态可手动关闭 | 澄清 #8 | +| 4 | 审批角色权限清单 | 部分答复:先预设角色,名称与职权待客户确认 | 按最小授权运行:后勤=提交录入,财务=复核+导出,领导=审核只读;营销后勤暂不可见审批列表;permissions/role_permissions 细目待清单 | 澄清 #6;催办一、B-6 | +| 5 | 风险预警规则 | 方向确认参数化;首批三条未点名 | 已按默认三条实现:`warning.enabled`、`warning.receipt.overdue.days=90`、`warning.approval.timeout.days=3`(V10);同步失败类待 ⑥ | 澄清 #10 | +| 6 | 调整单是否走同一审批链;能否跨月调整已锁月份 | 同一审批链;跨月需管理员授权并留痕 | 已落实:调整单走同一审批链;跨月调整需管理员授权并留痕 | 澄清 #9 | +| 7 | 待办事项范围 | 待审批 + 同步失败 + 回款未指定批次起步 | 已落实:工作台待办三项;⑥ 未建时同步项显式降级为「模块未启用」;待办按角色过滤 | 澄清 #11 | + +#### 关联项表(与《待确认事项汇总_2026-10-16》及催办清单的对应) + +| 3.3# | 澄清清单编号 | 催办清单位置 | 实现落点 | +| --- | --- | --- | --- | +| 1 | #1/#2/#3(#15 为作废条款关系) | 一、A-1 至 A-3 | 二次分摊拆行(ccfd206) | +| 2 | #5 | 已按答复执行(供核阅) | V8 迁移参数种子 | +| 3 | #8 | 已按答复执行(供核阅) | 月结取数(settlement_service) | +| 4 | #6 | 一、B-6 | permissions/role_permissions 待清单;当前按 role_code 粗粒度 | +| 5 | #10 | 已按答复执行(供核阅) | V10 参数 + dashboard_service | +| 6 | #9 | 已按答复执行(供核阅) | 调整单审批链与跨月授权 | +| 7 | #11 | 已按答复执行(供核阅) | dashboard_service 待办三源 | ### 3.4 对路线图的影响(本次已修订) -1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容;第三章待 3.3 各项澄清后,按第二章同粒度补齐 M3 各增量块的任务级实施计划。 +1.1 依赖图、1.3 范围与验收、1.4 里程碑、1.5 依据对照均已并入本批内容。M3 各增量块已于 2026-10-19 交付;3.3-1 仍无业务答复,二次分摊当前按开发侧暂定口径运行,答复后补口径变更计划;3.3-4 的权限细目与 3.3-5 的预警规则仍待业务确认。⑥ T+ 同步在 IT 答复前不排任务级计划。 ## 验证方式 1. 计划内容与 `ai-aux/AI_CONTEXT.md` 需求裁决节逐条核对(口径无冲突、无遗漏作废条款); 2. 计划中的命令与项目现有命令体系一致(uv / pnpm / docker compose); 3. 首块每个任务有独立可测交付物;测试要点符合「模块整体完成时统一编写」约定(纯函数与 core 级随实现即时测); -4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量 301+ 用例与 e2e 全绿。 +4. 执行阶段验证:T1 后 `test_schema_sync.py` 通过;T2/T3 后纯单元全绿;T5 后冒烟链路完整;T8 后全量后端用例与 e2e 全绿(以 `backend/tests/README.md` 最新运行记录为准)。 diff --git a/docs/待确认事项汇总_2026-10-16.md b/docs/待确认事项汇总_2026-10-16.md index cd1bcef..1993654 100644 --- a/docs/待确认事项汇总_2026-10-16.md +++ b/docs/待确认事项汇总_2026-10-16.md @@ -4,6 +4,7 @@ > 来源:《待澄清问题清单_2026-10-15.xlsx》(业务方 24 项)、《T+对接数据需求清单》v1.2.1(IT 18 项)、AI_CONTEXT 待办。 > 答复到齐前,各模块按清单内「建议默认」执行;答复与默认不同时以答复为准。 > **2026-10-19 更新**:业务答复落实批次与 M3 code-review 修复已落地(见各节标注);T+ 清单已升 v1.4。 +> **2026-10-20 更新**:开发计划 3.3 已回填(3.3.1),SRS V2.1 已交付;二次分摊已按开发侧暂定口径实现(#1–#3 答复仍空白)。 ## 一、立即待办(需项目内处理) @@ -14,16 +15,16 @@ ## 二、业务方澄清(24 项,**2026-10-16 答复已到**,回执见《待澄清问题清单_2026-10-15(1)(2).xlsx》H 列) -> 答复汇总:**12 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#23/#24);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;仍未落定见下表「答复状态」列。答复后待回填开发计划 3.3 与需求记录。 +> 答复汇总:**13 项确认按默认执行**(#7/#8/#9/#10/#11/#13/#15/#17/#18/#21/#22/#23/#24,其中 #22 为模糊确认、按不计入);**4 项答复与默认不同,以答复为准**——#5 二次分配审批开关**默认开启**、#14 **首日整 10‰**、#16 试用期基本工资**按人员档案逐人维护**、#19 备份**加增量补充**;部分答复 3 项(#4/#6/#12),仍开放 4 项(#1–#3/#20)。开发计划 3.3 已于 2026-10-20 回填(3.3.1),未答复项仍按催办清单跟进。 ### 高优先级(阻塞对应模块开工,8 项) | 清单编号 | 问题 | 建议默认(未答复时执行) | 阻塞模块 | 答复状态 | | --- | --- | --- | --- | --- | -| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 未答复前只做方案录入,不实现分摊(已按此交付) | ④ 分摊计算 | ❌ 未答复 | +| 1 | 二次分配的分配标的:绩效金额、折算毛利润还是业绩额? | 方案录入已交付;2026-10-19 已按开发侧暂定口径实现分摊拆行(非业务答复,答复后校准) | ④ 分摊计算 | ❌ 未答复 | | 2 | 分配比例合计不足 100% 时余额归谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | ❌ 未答复 | | 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ | ❌ 未答复 | -| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊实现(88 批次)进行中 | +| 4 | 二次分配方案的修改窗口与权限? | 首次月结前可改(留痕),月结后只读走调整单 | ③/④ | ⚠️ 部分:「暂定可改走调整单」,窗口/权限未定;分摊已于 2026-10-19 实现(ccfd206) | | 6 | 营销后勤/财务/领导的角色权限清单(可见菜单/可操作点)? | 最小授权:后勤=提交录入,财务=复核+导出,领导=审核只读 | ⑦ / permissions 激活 | ⚠️ 部分:「先预设角色」,名称与职权待客户确认;**#17 参数查看放开已落实(2026-10-19)** | | 8 | 回款何时算「确认回款」? | 需人工确认动作(系统已按此预留) | ④ 月结取数 | ✅ 确认:人工确认,可手动关闭 | | 9 | 调整单是否走同一审批链?能否跨月调整已锁月份? | 同一审批链;后续月份扣补为默认路径 | ④/⑦ | ✅ 确认默认;跨月需管理员授权并留痕 | @@ -41,7 +42,7 @@ | 15 | 支付权重「余额模式」是否需要? | ✅ 一期只用 FULL | | 16 | 试用期基本工资(3000 元)是统一参数还是逐人维护? | ✅ 已落实(2026-10-19,V8):撤销 `probation.salary.basic` 参数,基本工资读 `personnel.salary_standard` | | 20 | 含税口径:毛利/运费/价税合计是否含税? | ❌ 未答复:「需与客户再次确认具体计算方式」 | -| 22 | 预收款/保证金/代收款是否计入绩效回款? | ❌ 未答复:「当前未提到预收款情况」,暂按不计入 | +| 22 | 预收款/保证金/代收款是否计入绩效回款? | ⚠️ 模糊答复:「当前未提到预收款情况」,暂按不计入 | ### 低优先级(已有默认口径,顺手确认,7 项) @@ -83,7 +84,7 @@ | 里程碑 | 内容 | 前置条件 | | --- | --- | --- | -| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;**余**:二次分摊实现(#1–#3 答复空白)、业务答复回填批次收口(88 会话) | #20(含税口径)、#1–#3 答复后按同粒度推进 | +| M3 剩余 | ✅ ④ 核算引擎 + 月结/调整单 + ⑦ 月结审批对象已完成(2026-10-17 M3-core);**2026-10-19** 业务答复落实批次(首日 10‰/审批开关默认/试用期参数/增量备份/参数查看放开/经理基数开关)与 code-review 21 项修复已落地;二次分摊已按开发侧暂定口径实现(ccfd206),业务答复回填已完成(计划 3.3.1,2026-10-20);**余**:#1–#3/#20 答复与 #4/#6/#12 补值 | #20(含税口径)、#1–#3 答复后按同粒度推进 | | M4 | ⑤ 报表(签字导出模板)+ ⑧ 工作台 | ④ 已交付;报表口径确认后启动 | | M4(并行) | ⑥ T+ 数据同步 | IT 答复字段映射后 | | code-review 遗留 2 项 | 方案生效版本承载、审批单内容快照 | ④ 分摊语义落定时一并设计 | @@ -92,6 +93,6 @@ ## 五、文档待办(低优) - 需规过时条款未修订(26 天示例、含运费公式、协助 25%);2026-10-15 补充需求未回填需规——对外引用需规前须先修订或注明以对照表/计划第三章为准 -- 澄清答复已到(2026-10-16):待回填开发计划 3.3、AI_CONTEXT、《需求规格说明书》升 V2.1;《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4) +- 澄清答复已到(2026-10-16):开发计划 3.3 已回填(3.3.1,2026-10-20)、SRS V2.1 已交付(2026-10-19);《T+对接数据需求清单》已升 **v1.4**(2026-10-19,本节口径待收编;生成脚本 build_tplus_xlsx.py 已对齐 v1.3、待对齐 v1.4) - ~~新口径待落实(业务答复与默认不同的 4 项)~~ ✅ **已全部落实(2026-10-19,见第二节标注)** - **修复批次引入口径记录(2026-10-19,供业务知会)**:月结的经理聚合与月工资口径按**所属月月末**取参(单笔台账行仍按回款日取参,裁决 #11)——补生成历史月份时结果不随生成日漂移;如业务对月末时点有异议,改 `settlement_service._month_end` 单点即可 diff --git a/docs/待答复催办清单_2026-10-19.md b/docs/待答复催办清单_2026-10-19.md index 0a3540f..d73a9a2 100644 --- a/docs/待答复催办清单_2026-10-19.md +++ b/docs/待答复催办清单_2026-10-19.md @@ -1,7 +1,7 @@ # 待答复事项催办清单(2026-10-19) > **用途**:对外跟催材料,分三块可直接拆分发送——一、业务方;二、IT/T+ 侧;三、报表口径补充确认(本轮新增)。 -> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已回 15 项)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。 +> **基线**:《待澄清问题清单_2026-10-15(1)(2).xlsx》(业务 24 项,2026-10-16 已答复 19 项,其中部分答复 3 项;#1–#3/#20 仍空白)、《T+对接数据需求清单》v1.3「待IT确认事项」18 项 + v1.4(2026-10-19 勘误)、《问题与解决方案对照表_v2》(28 项已裁决口径)。 > **原则**:已答复项不重复询问;未答复项按清单内「建议默认」执行中,答复后以答复为准并回填文档。 ## 一、业务方(建议回复期限:____) @@ -10,12 +10,12 @@ | # | 问题(原文口径) | 当前执行默认 | 阻塞 | | --- | --- | --- | --- | -| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 未答复前只做方案录入,**不实现分摊计算**(已按此交付) | ④ 分摊计算 | +| 1 | 二次分配的分配标的是什么——绩效工资金额、折算毛利润额,还是销售额(业绩额)? | 方案录入已交付;**分摊已按开发侧暂定口径实现**(非业务答复,答复后校准) | ④ 分摊计算 | | 2 | 分配比例合计不足 100% 时,剩余部分归属谁? | 余额归合同负责营销人员(仅展示不落库) | ④ 分摊计算 | | 3 | 二次分配与已作废的「协助 25% 分成」是什么关系? | 独立新机制;协助仍仅记录不核算 | ④ 分摊 | | 20 | 含税口径:销售/成本/毛利润/运费各项是否含税?价税合计与折扣折让如何处理? | 按不含税口径实现中 | ④ 核算口径终验、⑥ 同步建列(是否建税率/税额列) | -> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批,但**分摊金额不会出现在绩效中**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。 +> 说明:#1–#3 未答复期间,二次分配方案可正常录入与审批;开发侧已按暂定口径把分摊拆行计入绩效(参与人份额×比例、负责人余额),**标的与余额归属仍待业务答复,答复后可能调整**;#20 影响历史算出的绩效是否需要按含税口径重算,越早答复返工面越小。 ### B. 已有答复但需补具体值 / 勾选(4 项) @@ -35,7 +35,7 @@ ### 已按默认执行、无需回复的项(供核阅) -#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。 +\#7 月结审批链默认开启、#8 人工确认回款可手动关闭、#9 调整单同链且跨月需授权、#10 风险预警参数化、#11 待办范围(待审批+同步失败+回款未指定批次)、#13 支付环节 5 默认值、#15 一期仅 FULL 模式、#17 参数查看放开/修改限管理员、#18 企业户 2‰ 回溯、#21 退货当期冲减已结算走调整单、#22 预收/保证金/代收款暂不计入、#23 按到账金额、#24 负绩效照算不归零。 ## 二、IT / T+ 侧(建议回复期限:____) diff --git a/frontend/README.md b/frontend/README.md index 9aa850c..e186291 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -188,7 +188,7 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py | 页面 | 说明 | | --- | --- | | `/reports/personal-detail` 个人绩效明细(新组「报表统计」order19) | 人员/月份/客户类型/产品类别/岗位筛选;需规 2.4.1 全部列;行展开懒加载台账快照;页脚**全筛选合计**(注明非当前页);导出/打印 | -| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片;团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) | +| `/reports/department-summary` 部门绩效汇总(order20) | 月份必填;全公司分成独立卡片(单经理 5% × 计入人数 = 合计,每位有聚合行的经理各计一次);团队表(基数/团队分成/全公司分成/占比/调整/净合计/月结冻结额),净合计与月结聚合行同口径,比例列头随在线参数变化;展开成员 → 跨页下钻个人明细(人员与月份带入筛选);未分配桶(按经理筛选时为空) | | `/reports/business-stats` 业务统计(order21) | 关键字/客户/归属人/状态/业务日期;发货额/回款额/绩效(累计口径提示);展开下钻回款与台账明细 | | `/reports/payment-stats` 回款统计(order22) | 月份/回款日期/客户类型/产品类别;8 档区间表(空桶 0,档位由后端下发);展开桶下钻分页明细;客户类型/产品类别对比表 | | `/reports/print/:code` 报表打印(顶层路由,`menu:false`) | 不挂 BasicLayout(打印不带侧栏/顶栏);页眉公司信息走 public 端点;循环翻页取全量(以接口 total 判定超过 2000 行并提示截断);部门汇总按「团队分成 → 全公司 5% → 对账 → 成员明细 → 未分配」五块渲染;`@media print` A4 横版 + 每页表头;签字栏;PDF 由浏览器「另存为」覆盖 | @@ -197,6 +197,7 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py - api 封装 `src/api/reports.ts`(类型 + 查询 + `exportReport` 裸 axios blob;产品类别标签额外覆盖 `mixed`)。 - 权限:营销后勤「授权范围内」待业务权限清单 #6,当前不开放报表。 - type-check 与 build 通过(2026-10-19)。e2e `report_flow.py` 已补 12 步:登录 → API 造数 → 四表筛选/导出下载 → 部门下钻筛选生效 → 回款多桶同时展开互不串行 → 打印视图 → 打印截断提示 → 部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 且前后端在跑。 +- **e2e 实跑(2026-10-20)**:`login_flow` 8/8、`system_management` 13/13、`role_home_flow` 4/4、`report_flow` 12/12、`calc_settlement_flow` 8/8 全过。`report_flow` 的回款统计改为展开目标桶后按**本次业务编号**定位明细行,不依赖桶累计值,脚本可重复运行(历史 e2e 数据累计不再造成假失败)。 ## 端到端测试(2026-10-19 追加 · M3 台账与月结) @@ -225,4 +226,7 @@ E2E_ADMIN_PASSWORD=<开发库admin密码> python e2e/system_management.py - **ECharts 5.6 按需引入**:`echarts/core` + `BarChart/LineChart/Grid/Tooltip` + `SVGRenderer`(与设计预览一致),只注册用到的模块。 - **字体红线**:图表 `textStyle.fontFamily` 必须显式设为思源黑体栈——ECharts 在 Windows 默认微软雅黑(字体商用合规要求)。 - 权限:路由 `allowedRoles: [admin, finance, leader]`,与后端 `require_roles` 双层;营销后勤待权限清单 #6。 +- **普通用户落地页**:守卫不再硬编码回 `/dashboard`,统一走 `src/router/access.ts` 的 `firstAccessiblePath`(工作台 → 业务数据 → 回款),普通用户登录/刷新/直连受限页均落 `/business/businesses`,不会无限重定向;登录回跳也经 `canAccessPath` 校验。菜单与面包屑复用同一份准入判定。 +- 预警徽标用后端 `warnings.total`(全部命中数),`items` 仅为展示上限内的子集。 - `package.json` 新增依赖 `echarts@5.6.0`(MIT);type-check 与 build 通过(2026-10-19)。 +- e2e `e2e/role_home_flow.py`:普通用户登录落地、菜单隐藏、刷新保持、直连 `/dashboard` 与报表页回退;需 `E2E_ADMIN_PASSWORD`。 diff --git a/frontend/e2e/common.py b/frontend/e2e/common.py index 7b56dee..e0ab5c0 100644 --- a/frontend/e2e/common.py +++ b/frontend/e2e/common.py @@ -112,10 +112,12 @@ def expect_toast(page: Page, expected: str, step: str, timeout_ms: int = 5000) - fail(step, f"未出现提示「{expected}」,当前提示:{text or '(无)'}") -def login(page: Page, username: str, password: str) -> None: - """完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入首页。 +def login(page: Page, username: str, password: str, expected_path: str = "/dashboard") -> None: + """完整登录:打开登录页 → 从 Redis 取验证码答案 → 提交 → 等待进入落地页。 验证码 id 经响应监听捕获(前端把它内联在响应体里,不是 URL 参数)。 + ``expected_path`` 按角色传入:管理员/财务/领导默认工作台,普通用户 + 落业务数据页(工作台权限收紧后守卫按角色解析首页)。 """ captcha_ids: list[str] = [] @@ -148,8 +150,8 @@ def login(page: Page, username: str, password: str) -> None: page.fill('input[autocomplete="off"]', answer) page.click("button:has-text('登 录')") page.wait_for_timeout(2000) - if current_path(page) != "/dashboard": - detail = f"未跳转到首页,当前 {page.url}" + if current_path(page) != expected_path: + detail = f"未跳转到 {expected_path},当前 {page.url}" if message := toast_text(page): detail += f",提示:{message}" fail("登录", detail) diff --git a/frontend/e2e/report_flow.py b/frontend/e2e/report_flow.py index 1998fbf..ec7e5f2 100644 --- a/frontend/e2e/report_flow.py +++ b/frontend/e2e/report_flow.py @@ -159,6 +159,7 @@ 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"], } @@ -216,7 +217,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 +226,143 @@ 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 档区间与目标桶金额正确") + # 按本次业务编号在下钻明细中定位,不依赖桶聚合值(历史 e2e 数据会累计到同桶) + bucket.locator(".el-table__expand-icon").click() + page.wait_for_timeout(1000) + drill_row = page.locator( + ".drill-wrap .el-table__row", has_text=ctx["business_no"] + ).first + if drill_row.count() == 0: + fail("回款统计", f"61-90 天桶下钻未找到本次业务 {ctx['business_no']}") + drill_text = drill_row.inner_text() + if "10000.00" not in drill_text: + fail("回款统计", f"本次业务下钻行金额不符:{drill_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 +381,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 +394,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/e2e/role_home_flow.py b/frontend/e2e/role_home_flow.py new file mode 100644 index 0000000..7bc4765 --- /dev/null +++ b/frontend/e2e/role_home_flow.py @@ -0,0 +1,141 @@ +"""浏览器端到端验证:普通用户落地页与路由守卫回退。 + +工作台收紧为 admin/finance/leader 后,登录默认页不能硬编码 `/dashboard` +(普通用户会被无限重定向)。本脚本验证: + +- 普通用户登录后落业务数据页,不出现「无权访问」提示 +- 侧边菜单不出现首页/系统管理/报表统计 +- 刷新后仍保持登录态与落地页 +- 直连 `/dashboard` 或报表页时回退业务数据页并给出权限提示 + +运行前置:容器、后端 8000、前端 5173 均在运行;设置 ``E2E_ADMIN_PASSWORD``。 + +运行:: + + cd frontend && E2E_ADMIN_PASSWORD=<密码> python e2e/role_home_flow.py +""" + +import json +import os +import time +import urllib.error +import urllib.request +import uuid + +from playwright.sync_api import Page, sync_playwright + +from common import BASE_URL, E2EFailure, current_path, fail, login, toast_text + +ADMIN = "admin" +USERNAME = f"e2e_role_{int(time.time())}_{uuid.uuid4().hex[:4]}" +PASSWORD = "E2eRole@2026" +HOME_PATH = "/business/businesses" + + +def _api(method: str, path: str, token: str, body: dict | None = None) -> dict: + """带令牌的轻量 API 调用(urllib,避免额外依赖)。""" + request = urllib.request.Request( + f"{BASE_URL}{path}", + method=method, + headers={ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }, + data=json.dumps(body).encode() if body is not None else None, + ) + try: + with urllib.request.urlopen(request, timeout=20) as response: + payload = json.loads(response.read().decode()) + except urllib.error.HTTPError as exc: + detail = exc.read().decode()[:200] + raise E2EFailure(f"API {method} {path} HTTP {exc.code}:{detail}") from exc + if payload.get("code") != 200: + raise E2EFailure(f"API {method} {path} 业务失败:{payload.get('message')}") + return payload["data"] + + +def _token(page: Page) -> str: + token = page.evaluate("localStorage.getItem('ys_token')") + if not token: + fail("API", "未取到登录令牌") + return token + + +def _create_user(page: Page) -> dict: + return _api( + "POST", + "/api/v1/system/users", + _token(page), + { + "username": USERNAME, + "password": PASSWORD, + "real_name": "E2E普通用户", + "roles": ["user"], + }, + ) + + +def run(page: Page) -> None: + admin_password = os.environ.get("E2E_ADMIN_PASSWORD", "") + if not admin_password: + fail("环境变量", "未设置 E2E_ADMIN_PASSWORD(开发库 admin 密码)") + + login(page, ADMIN, admin_password) + user = _create_user(page) + try: + page.evaluate("localStorage.clear()") + login(page, USERNAME, PASSWORD, expected_path=HOME_PATH) + page.wait_for_timeout(500) + print("✓ 1. 普通用户登录 落地业务数据页") + + menu_text = page.locator(".app-menu").inner_text() + for forbidden in ("首页", "系统管理", "报表统计"): + if forbidden in menu_text: + fail("菜单权限", f"普通用户看到了「{forbidden}」菜单") + print("✓ 2. 菜单权限 首页/系统管理/报表统计均不可见") + + page.reload(wait_until="networkidle") + page.wait_for_timeout(600) + if current_path(page) != HOME_PATH: + fail("刷新恢复", f"刷新后未保持落地页,当前 {page.url}") + print("✓ 3. 刷新恢复 登录态与落地页保持") + + for path in ("/dashboard", "/reports/personal-detail"): + page.goto(f"{BASE_URL}{path}", wait_until="networkidle") + page.wait_for_timeout(800) + if current_path(page) != HOME_PATH: + fail("路由守卫", f"直连 {path} 未回退业务数据页,当前 {page.url}") + if "无权访问该页面" not in toast_text(page): + fail("路由守卫", f"直连 {path} 未给出权限提示") + print("✓ 4. 路由守卫 受限页均回退业务数据页并提示") + finally: + # 无论用例是否失败都清理测试账号(重新以 admin 登录取令牌) + page.evaluate("localStorage.clear()") + login(page, ADMIN, admin_password) + _api("DELETE", f"/api/v1/system/users/{user['id']}", _token(page)) + + +def main() -> int: + with sync_playwright() as playwright: + browser = playwright.chromium.launch(headless=True) + page = browser.new_page(viewport={"width": 1440, "height": 900}) + page.set_default_timeout(15000) + try: + run(page) + except E2EFailure as exc: + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ {exc}") + print(" 已保存截图 e2e/failure.png") + return 1 + except Exception as exc: # noqa: BLE001 — 测试脚本需打印完整失败原因 + page.screenshot(path="e2e/failure.png", full_page=True) + print(f"\n ✗ 执行中断:{type(exc).__name__}: {exc}") + return 1 + finally: + browser.close() + print("\n全部通过。") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/frontend/e2e/system_management.py b/frontend/e2e/system_management.py index cb4ace2..8d69713 100644 --- a/frontend/e2e/system_management.py +++ b/frontend/e2e/system_management.py @@ -249,18 +249,18 @@ def step_permission_check(page: Page, admin_password: str) -> None: page.click("text=退出登录") page.wait_for_url("**/login**", timeout=10_000) - login(page, VIEWER_USERNAME, E2E_PASSWORD) + login(page, VIEWER_USERNAME, E2E_PASSWORD, expected_path="/business/businesses") if page.locator(".app-menu").get_by_text("系统管理", exact=True).count() != 0: fail("菜单权限", "普通用户看到了「系统管理」菜单") print("✓ 11. 菜单权限 普通用户看不到「系统管理」分组") page.goto(f"{BASE_URL}/system/users", wait_until="networkidle") page.wait_for_timeout(800) - if current_path(page) != "/dashboard": + if current_path(page) != "/business/businesses": fail("路由守卫", f"普通用户直连管理页未被弹回,当前 {page.url}") if "需要管理员权限" not in toast_text(page): fail("路由守卫", "直连管理页未给出权限提示") - print("✓ 12. 路由守卫 直连 /system/users 被弹回首页") + print("✓ 12. 路由守卫 直连 /system/users 被弹回业务数据页") page.click("text=退出登录") page.wait_for_url("**/login**", timeout=10_000) 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/dashboard.ts b/frontend/src/api/dashboard.ts index 3dba29e..1cf1153 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -67,7 +67,10 @@ export interface WarningBlock { enabled: boolean receipt_overdue_days: number approval_timeout_days: number + /** 当前返回的条目(受展示上限约束) */ items: WarningItem[] + /** 全部规则命中总数;徽标计数用它,不能用 items.length */ + total: number receipt_overdue_total: number approval_timeout_total: number sync_available: boolean 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/components/layout/BasicLayout.vue b/frontend/src/components/layout/BasicLayout.vue index be56057..052f0d2 100644 --- a/frontend/src/components/layout/BasicLayout.vue +++ b/frontend/src/components/layout/BasicLayout.vue @@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import AppMenu from '@/components/layout/AppMenu.vue' +import { firstAccessiblePath } from '@/router/access' import { useUserStore } from '@/store/user' /** 基础布局:左侧品牌导航(窄屏收为抽屉)+ 顶栏(面包屑/用户/退出)+ 内容区。 */ @@ -13,11 +14,14 @@ const userStore = useUserStore() const drawerVisible = ref(false) +/** 「首页」按角色解析:普通用户没有工作台权限,固定链到业务数据页 */ +const homePath = computed(() => firstAccessiblePath(router, userStore.roles)) + /** 面包屑:首页 + 分组(如有)+ 当前页标题(首页自身不重复) */ const breadcrumbs = computed(() => { const items: string[] = [] if (route.meta.group) items.push(route.meta.group) - if (route.meta.title && route.path !== '/dashboard') items.push(route.meta.title) + if (route.meta.title && route.path !== homePath.value) items.push(route.meta.title) return items }) @@ -66,7 +70,7 @@ async function handleLogout(): Promise { - 首页 + 首页 {{ item }} diff --git a/frontend/src/composables/useAppMenu.ts b/frontend/src/composables/useAppMenu.ts index 2bc464b..966f36b 100644 --- a/frontend/src/composables/useAppMenu.ts +++ b/frontend/src/composables/useAppMenu.ts @@ -1,8 +1,8 @@ import { computed } from 'vue' import { useRouter } from 'vue-router' +import { canAccessRoute, isMenuRecord } from '@/router/access' import { useUserStore } from '@/store/user' -import { ROLE_ADMIN } from '@/utils/constants' /** 菜单节点:从路由表派生,避免菜单标题与页面标题两处维护漂移。 */ export interface MenuNode { @@ -30,19 +30,13 @@ export function useAppMenu() { const router = useRouter() const userStore = useUserStore() - const isAdmin = computed(() => userStore.roles.includes(ROLE_ADMIN)) const userRoles = computed(() => userStore.roles) const menuNodes = computed(() => router .getRoutes() - .filter((record) => Boolean(record.meta.title) && record.meta.menu !== false) - .filter((record) => !record.meta.requiresAdmin || isAdmin.value) - // 职能页面(审批等):任一列入角色命中即可见(admin 须显式列入集合) - .filter( - (record) => - !record.meta.allowedRoles || record.meta.allowedRoles.some((role) => userRoles.value.includes(role)), - ) + // 准入规则与路由守卫、落地页解析共用,避免三处漂移 + .filter((record) => isMenuRecord(record) && canAccessRoute(record, userRoles.value)) .map((record) => ({ path: record.path, title: record.meta.title ?? '', 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/router/access.ts b/frontend/src/router/access.ts new file mode 100644 index 0000000..4d0cda9 --- /dev/null +++ b/frontend/src/router/access.ts @@ -0,0 +1,62 @@ +import type { RouteRecordNormalized, Router } from 'vue-router' + +import { ROLE_ADMIN } from '@/utils/constants' + +/** + * 路由准入与落地页解析(菜单、守卫、登录回跳共用一份规则)。 + * + * 之前守卫两处兜底硬编码 `/dashboard`,工作台收紧权限后普通用户会被 + * 反复重定向到同一受限页(无限循环)。集中在这里后,任何角色的落地页 + * 都必须先通过同一准入判定。 + */ + +/** 可作为菜单项/落地页:有标题、未被 menu:false 排除、非免登录页 */ +export function isMenuRecord(record: RouteRecordNormalized): boolean { + return Boolean(record.meta.title) && record.meta.menu !== false && !record.meta.public +} + +/** 当前角色是否可访问该路由(与守卫、菜单过滤同源) */ +export function canAccessRoute(record: RouteRecordNormalized, roles: string[]): boolean { + if (record.meta.public) return false + if (record.meta.requiresAdmin && !roles.includes(ROLE_ADMIN)) return false + if ( + record.meta.allowedRoles && + !record.meta.allowedRoles.some((role) => roles.includes(role)) + ) { + return false + } + return true +} + +/** + * 落地页候选(按优先级):工作台 → 业务数据 → 回款。 + * 普通用户没有工作台权限时落到业务数据页,而不是被菜单 order 带到 + * 基础数据管理页;候选都不可访问时再按菜单顺序兜底。 + */ +const HOME_CANDIDATES = ['/dashboard', '/business/businesses', '/business/receipts'] + +function matchedRecord(router: Router, path: string): RouteRecordNormalized | undefined { + const matched = router.resolve(path).matched + return matched[matched.length - 1] +} + +/** 当前角色可访问的默认首页;无任何可访问页时回登录页(防循环) */ +export function firstAccessiblePath(router: Router, roles: string[]): string { + for (const path of HOME_CANDIDATES) { + const record = matchedRecord(router, path) + if (record && isMenuRecord(record) && canAccessRoute(record, roles)) return path + } + const fallback = router + .getRoutes() + .filter(isMenuRecord) + .filter((record) => canAccessRoute(record, roles)) + .sort((a, b) => (a.meta.order ?? 99) - (b.meta.order ?? 99)) + return fallback[0]?.path ?? '/login' +} + +/** 登录回跳目标是否可信且可访问(防开放重定向与回跳受限页) */ +export function canAccessPath(router: Router, path: string, roles: string[]): boolean { + if (!path.startsWith('/') || path.startsWith('//')) return false + const record = matchedRecord(router, path) + return Boolean(record && isMenuRecord(record) && canAccessRoute(record, roles)) +} diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 8abf96e..0dbc0b0 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -1,6 +1,7 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' import { ElMessage } from 'element-plus' +import { firstAccessiblePath } from '@/router/access' import { useUserStore } from '@/store/user' import { ROLE_ADMIN } from '@/utils/constants' @@ -265,14 +266,17 @@ router.beforeEach(async (to) => { } if (to.meta.requiresAdmin && !userStore.roles.includes(ROLE_ADMIN)) { - ElMessage.error('需要管理员权限') - return { path: '/dashboard' } + const home = firstAccessiblePath(router, userStore.roles) + // 根路径/catch-all 的重定向不提示;用户手输受限 URL 才提示 + if (!to.redirectedFrom) ElMessage.error('需要管理员权限') + return home === to.path ? { path: '/login' } : { path: home } } // 职能页面(审批等):任一角色命中即可进;菜单隐藏之外的第二道防线 if (to.meta.allowedRoles && !to.meta.allowedRoles.some((role) => userStore.roles.includes(role))) { - ElMessage.error('无权访问该页面') - return { path: '/dashboard' } + const home = firstAccessiblePath(router, userStore.roles) + if (!to.redirectedFrom) ElMessage.error('无权访问该页面') + return home === to.path ? { path: '/login' } : { path: home } } return true 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/dashboard/index.vue b/frontend/src/views/dashboard/index.vue index 05a081a..21d1419 100644 --- a/frontend/src/views/dashboard/index.vue +++ b/frontend/src/views/dashboard/index.vue @@ -42,6 +42,8 @@ const ORDER_TYPE_LABELS: Record = { const month = ref(currentMonth()) const data = ref(null) const loading = ref(false) +/** 快速切换月份时,只允许最新请求写入数据与 loading */ +let loadSeq = 0 const perfChartRef = ref(null) const receiptChartRef = ref(null) @@ -54,15 +56,18 @@ function currentMonth(): string { } async function load(): Promise { + const seq = ++loadSeq loading.value = true try { - data.value = await dashboardApi.getDashboard({ month: month.value }) + const result = await dashboardApi.getDashboard({ month: month.value }) + if (seq !== loadSeq) return // 已有更新的请求,丢弃过期响应 + data.value = result await nextTick() renderCharts() } catch (error) { - handledError(error) + if (seq === loadSeq) handledError(error) } finally { - loading.value = false + if (seq === loadSeq) loading.value = false } } @@ -294,7 +299,7 @@ function formatTime(value: string): string {
风险预警 - {{ data?.warnings.items.length ?? 0 }} + {{ data?.warnings.total ?? 0 }}
预警已关闭(参数 warning.enabled=0) @@ -322,6 +327,12 @@ function formatTime(value: string): string { 暂无预警(回款逾期 {{ data?.warnings.receipt_overdue_days ?? 90 }} 天 / 审批超时 {{ data?.warnings.approval_timeout_days ?? 3 }} 天)
+
+ 仅显示前 {{ data.warnings.items.length }} 条,共 {{ data.warnings.total }} 条 +
同步异常预警
{{ data?.warnings.sync_available ? '已接入' : '同步模块未启用(待 T+ 对接)' }} diff --git a/frontend/src/views/login/index.vue b/frontend/src/views/login/index.vue index 3b279ab..bee99f8 100644 --- a/frontend/src/views/login/index.vue +++ b/frontend/src/views/login/index.vue @@ -4,6 +4,7 @@ import { onMounted, reactive, ref } from 'vue' import { useRoute, useRouter } from 'vue-router' import { getCaptcha } from '@/api/auth' +import { canAccessPath, firstAccessiblePath } from '@/router/access' import { useUserStore } from '@/store/user' const router = useRouter() @@ -87,8 +88,15 @@ async function handleSubmit(): Promise { captcha_id: captchaId.value, captcha_code: form.captcha_code.trim(), }) + // 先拉角色再解析落地页:普通用户不能回跳到受限的工作台 + await userStore.fetchUserInfo() ElMessage.success('登录成功') - await router.replace((route.query.redirect as string) || '/dashboard') + const redirect = route.query.redirect + const target = + typeof redirect === 'string' && canAccessPath(router, redirect, userStore.roles) + ? redirect + : firstAccessiblePath(router, userStore.roles) + await router.replace(target) } catch { // **必须刷新验证码**:后端用 GETDEL 消费验证码,无论何种失败它都已失效。 // 不刷新的话,用户第二次提交必然报「验证码错误」,且极难自查原因。 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 @@