From 6ff202d637a82ce86aa425d53183e774b9729e98 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 19 Oct 2026 18:44:15 +0800 Subject: [PATCH] =?UTF-8?q?feat(dashboard):=20=E5=B7=A5=E4=BD=9C=E5=8F=B0?= =?UTF-8?q?=E5=9B=9B=E5=8C=BA=E5=9D=97=E5=AE=9E=E6=97=B6=E8=81=9A=E5=90=88?= =?UTF-8?q?=E4=B8=8E=E9=A3=8E=E9=99=A9=E9=A2=84=E8=AD=A6=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端 /api/v1/dashboard 单端点返回四区块:经营总览(绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步显式降级)、风险预警(回款逾期/审批超时,阈值参数化)、最近动态(操作日志) - dashboard_service 实时派生(不落表、不引调度器);参数快照一次解析;总览只做 SQL 聚合,不复用报表的重查询 - V10 迁移新增预警参数三项(warning.enabled / warning.receipt.overdue.days=90 / warning.approval.timeout.days=3),参数注册表 19→22,init-dev 与 V10 SQL 双轨同步 - 测试:test_dashboard 5 例(权限文案/总览对账+非法月份 400/待办三类/预警命中与参数关闭/动态来源);全量 852 passed - 依赖闭包并入:core/month.py 月份校验、api/v1/base/business_roles.py(router 已挂载的岗位只读端点)、test_month_validation - 修复:alembic --sql 在 Windows 按控制台 GBK 输出,V10 SQL 以 UTF-8 重新导出(README 已记) Co-Authored-By: Claude Code --- backend/README.md | 27 +- ...814a421_v10_工作台预警规则参数.py | 143 ++++++++++ backend/app/api/router.py | 5 + backend/app/api/v1/base/business_roles.py | 40 +++ backend/app/api/v1/dashboard.py | 39 +++ backend/app/core/month.py | 26 ++ .../app/repositories/dashboard_repository.py | 209 +++++++++++++++ backend/app/schemas/dashboard.py | 125 +++++++++ backend/app/services/dashboard_service.py | 245 ++++++++++++++++++ backend/app/services/param_registry.py | 48 ++++ backend/tests/README.md | 32 ++- backend/tests/integration/conftest.py | 10 +- backend/tests/integration/test_dashboard.py | 232 +++++++++++++++++ .../integration/test_param_management.py | 9 +- backend/tests/unit/test_month_validation.py | 55 ++++ backend/tests/unit/test_param_registry.py | 12 +- db/init/init-dev.sql | 14 +- db/migration/V10__warning_rule_params.sql | 16 ++ 18 files changed, 1262 insertions(+), 25 deletions(-) create mode 100644 backend/alembic/versions/90653814a421_v10_工作台预警规则参数.py create mode 100644 backend/app/api/v1/base/business_roles.py create mode 100644 backend/app/api/v1/dashboard.py create mode 100644 backend/app/core/month.py create mode 100644 backend/app/repositories/dashboard_repository.py create mode 100644 backend/app/schemas/dashboard.py create mode 100644 backend/app/services/dashboard_service.py create mode 100644 backend/tests/integration/test_dashboard.py create mode 100644 backend/tests/unit/test_month_validation.py create mode 100644 db/migration/V10__warning_rule_params.sql diff --git a/backend/README.md b/backend/README.md index d840224..4f89b82 100644 --- a/backend/README.md +++ b/backend/README.md @@ -129,6 +129,16 @@ app/ 准入 `require_roles(admin, finance, leader)`(FR-030/031;FR-029 的「营销后勤授权范围内」 待业务权限清单 #6)。前端菜单同层过滤(路由 `allowedRoles`)。 +### 工作台接口(M4 ⑧,限管理员/财务/领导) + +| 方法 | 路径 | 说明 | +| --- | --- | --- | +| GET | `/api/v1/dashboard?month=YYYY-MM` | 四区块一次性返回:经营绩效总览(当月绩效/应付/人数/团队数/月结状态/近 6 个月趋势)、待办(待审批 + 回款未指定批次 + 同步降级)、风险预警(回款逾期/审批超时 + 同步降级)、最近动态(操作日志) | + +待办与预警**实时派生**(不落表、不引调度器);预警阈值走参数体系 +(`warning.enabled` / `warning.receipt.overdue.days` / `warning.approval.timeout.days`, +V10 种子);⑥ T+ 同步未建,同步相关条目以 `sync_available=false` 显式降级。 + ## 常用命令 ```bash @@ -211,15 +221,26 @@ bcrypt 5.0 起对超过 72 字节的密码**直接抛 ValueError**(此前是 - **留痕表的固化设计**:`ownership_change_logs` 与 `business_status_logs` 对客户/业务/人员 ID 均**无外键**(冗余固化,被引用方删除后记录仍完整可读);RESTRICT 外键反而会把删除逼成 500。删除保护全部由服务层检查承担(人员→团队成员/客户归属引用;客户→业务引用;业务→发货/回款记录)。两张留痕表 append-only、不继承 TimestampMixin。 - **金额契约**:schema 入参出参一律 `str`,小数超两位直接拒绝(不静默舍入);服务层 `Decimal`,比率 `Fraction`;毛利润率出参为分数原样字符串(`"997/1000"`,同 M1 计算预览口径),前端原样展示禁止转 float。 +### 工作台口径(M4 ⑧,改动前先读) + +- **只读聚合**:总览只做 SQL 聚合(SUM / COUNT DISTINCT),不复用报表的部门汇总(后者加载整月台账,过重);人数用 `COUNT(DISTINCT personnel_id)`——二次分摊拆行会让行数虚高。 +- **待办/预警实时派生**:查询即算,不建待办表/预警表。审批超时取 `approval_orders.create_time`(待审批状态);回款逾期按「发货单 `ship_date` 超阈值且该批次无任何回款」判定;回款未指定批次 = `receipts.shipment_id IS NULL`。 +- **同步降级**:⑥ 未建时 `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` 前缀。 + ### 报表口径(M4 ⑤,改动前先读) - **数据源固定单笔生效行**:四张报表的明细一律 `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` 是两个口径。 +- **调整单并入**:仅 `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% 分列**——两项之和才是聚合行金额。 +- **人员归属按行类型**:普通台账行跟随 `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` 指定。 -- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;集成测试逐项断言。 +- **导出**:openpyxl 普通 Workbook + `Decimal` 原值 + `number_format`(不写 Excel 公式);超 `REPORT_EXPORT_MAX_ROWS`(20000)行返回 400;文本列防公式注入(`= + - @` 前缀加 `'`);服务端文件名为 ASCII(响应头 latin-1 限制),中文名由前端 `link.download` 指定。部门汇总是**单 sheet 分块签字稿**(团队分成汇总 → 全公司 5% 单列 → 对账汇总 → 成员明细 → 未分配明细 → 签字栏);全公司分成只出现一次,调整净额只在明细层求和,应发合计 = 团队成员应付 + 未分配应付 + 团队分成合计 + 全公司分成 + 调整净额合计。筛选页眉把人员/经理/客户/归属人/状态转为名称与中文标签;回款统计两个维度 sheet 共用主表筛选条件。 +- **导出审计**:`audit` 上下文覆盖取数与构建全过程,超限 400 和取数异常都会写失败操作日志(`success=0`);成功摘要为静态报表标题。 +- **汇总 = 明细合计**:团队/公司基数由台账分桶结果直接得出,报表侧不另算一遍;未筛选时 `团队基数 + 未分配绩效 = 全公司基数`;集成测试逐项断言。 +- **岗位筛选选项**:`GET /api/v1/base/business-roles/options` 仅要求登录(含停用岗位),供财务/领导在报表中按岗位筛选;岗位管理接口仍仅管理员。 ## 常见问题 diff --git a/backend/alembic/versions/90653814a421_v10_工作台预警规则参数.py b/backend/alembic/versions/90653814a421_v10_工作台预警规则参数.py new file mode 100644 index 0000000..0ec5797 --- /dev/null +++ b/backend/alembic/versions/90653814a421_v10_工作台预警规则参数.py @@ -0,0 +1,143 @@ +"""v10 工作台预警规则参数 + +Revision ID: 90653814a421 +Revises: ba0217f5091b +Create Date: 2026-10-19 17:55:48.892998 + +新增风险预警三条参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」): +- warning.enabled:总开关(默认 1) +- warning.receipt.overdue.days:发货后逾期天数(默认 90) +- warning.approval.timeout.days:审批超时天数(默认 3) + +「同步连续失败」规则依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目。 +""" +from collections.abc import Sequence +from datetime import date + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '90653814a421' +down_revision: str | Sequence[str] | None = 'ba0217f5091b' +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + +_EFFECTIVE_DATE = date(2026, 10, 19) + + +def upgrade() -> None: + """种入三个预警参数定义与生效值(append-only 时点化)。""" + op.bulk_insert( + sa.table( + "param_definitions", + sa.column("id", sa.BigInteger), + sa.column("param_code", sa.String), + sa.column("param_name", sa.String), + sa.column("param_group", sa.String), + sa.column("value_type", sa.String), + sa.column("unit", sa.String), + sa.column("dimension_type", sa.String), + sa.column("options_text", sa.String), + sa.column("default_value", sa.String), + sa.column("value_min", sa.String), + sa.column("value_max", sa.String), + sa.column("description", sa.String), + sa.column("sort_order", sa.Integer), + sa.column("status", sa.Integer), + ), + [ + { + "id": 21, + "param_code": "warning.enabled", + "param_name": "风险预警总开关", + "param_group": "预警规则", + "value_type": "BOOL", + "unit": None, + "dimension_type": "NONE", + "options_text": None, + "default_value": "1", + "value_min": None, + "value_max": None, + "description": "工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)", + "sort_order": 21, + "status": 1, + }, + { + "id": 22, + "param_code": "warning.receipt.overdue.days", + "param_name": "回款逾期天数阈值", + "param_group": "预警规则", + "value_type": "INT", + "unit": "天", + "dimension_type": "NONE", + "options_text": None, + "default_value": "90", + "value_min": "1", + "value_max": None, + "description": "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)", + "sort_order": 22, + "status": 1, + }, + { + "id": 23, + "param_code": "warning.approval.timeout.days", + "param_name": "审批超时天数阈值", + "param_group": "预警规则", + "value_type": "INT", + "unit": "天", + "dimension_type": "NONE", + "options_text": None, + "default_value": "3", + "value_min": "1", + "value_max": None, + "description": "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)", + "sort_order": 23, + "status": 1, + }, + ], + ) + op.bulk_insert( + sa.table( + "param_values", + sa.column("id", sa.BigInteger), + sa.column("param_id", sa.BigInteger), + sa.column("dimension_value", sa.String), + sa.column("effective_date", sa.Date), + sa.column("value_text", sa.String), + sa.column("batch_id", sa.BigInteger), + ), + [ + { + "id": 26, + "param_id": 21, + "dimension_value": "", + "effective_date": _EFFECTIVE_DATE, + "value_text": "1", + "batch_id": 1, + }, + { + "id": 27, + "param_id": 22, + "dimension_value": "", + "effective_date": _EFFECTIVE_DATE, + "value_text": "90", + "batch_id": 1, + }, + { + "id": 28, + "param_id": 23, + "dimension_value": "", + "effective_date": _EFFECTIVE_DATE, + "value_text": "3", + "batch_id": 1, + }, + ], + ) + + +def downgrade() -> None: + """回滚种子行(行级种子不会被 drop_table 带走,必须显式删除)。""" + op.execute("DELETE FROM param_values WHERE id IN (26, 27, 28)") + op.execute("DELETE FROM param_definitions WHERE id IN (21, 22, 23)") diff --git a/backend/app/api/router.py b/backend/app/api/router.py index 0191053..6bd6b02 100644 --- a/backend/app/api/router.py +++ b/backend/app/api/router.py @@ -8,6 +8,7 @@ from fastapi import APIRouter from app.api import health from app.api.v1 import auth +from app.api.v1.base import business_roles as base_business_roles from app.api.v1.base import customers as base_customers from app.api.v1.base import personnel as base_personnel from app.api.v1.base import products as base_products @@ -16,6 +17,7 @@ from app.api.v1.business import ratings as business_ratings from app.api.v1.business import receipts as business_receipts from app.api.v1.business import shipments as business_shipments from app.api.v1.calc import router as calc_router +from app.api.v1.dashboard import router as dashboard_router from app.api.v1.reports import router as reports_router from app.api.v1.settlements import router as settlements_router from app.api.v1.system import approvals as system_approvals @@ -52,6 +54,7 @@ base_router = APIRouter(prefix="/base") base_router.include_router(base_personnel.router) base_router.include_router(base_products.router) base_router.include_router(base_customers.router) +base_router.include_router(base_business_roles.router) v1_router.include_router(base_router) # 业务数据(需求 2.2):/api/v1/business/*,业务操作登录即可访问 @@ -68,5 +71,7 @@ v1_router.include_router(calc_router) v1_router.include_router(settlements_router) # 报表统计(M4 ⑤):/api/v1/reports/*,只读,限管理员/财务/领导 v1_router.include_router(reports_router) +# 工作台(M4 ⑧):/api/v1/dashboard,四区块只读聚合,限管理员/财务/领导 +v1_router.include_router(dashboard_router) api_router.include_router(v1_router) diff --git a/backend/app/api/v1/base/business_roles.py b/backend/app/api/v1/base/business_roles.py new file mode 100644 index 0000000..6244ad7 --- /dev/null +++ b/backend/app/api/v1/base/business_roles.py @@ -0,0 +1,40 @@ +"""岗位下拉选项(基础数据与报表筛选共用)。 + +岗位管理本身仅管理员可用(FR-006),但报表的「岗位」筛选对财务/领导 +开放(FR-029)。为避免把管理接口整体放开,这里提供只读、登录即可的 +启用岗位选项;写操作仍走 `/system/business-roles` 的管理员端点。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends + +from app.api.deps import DbSession, get_current_user +from app.core.response import ApiResponse, ok +from app.models import User +from app.repositories import business_role_repository + +router = APIRouter(prefix="/business-roles", tags=["人员管理"]) + +# 基础数据是业务操作:登录即可,不要求管理员 +CurrentUser = Annotated[User, Depends(get_current_user)] + + +@router.get("/options", response_model=ApiResponse[list[dict]], summary="岗位下拉选项") +def list_options(session: DbSession, _: CurrentUser) -> ApiResponse[list[dict]]: + """岗位选项(登录即可;含停用——人员档案可能仍引用停用岗位)。 + + 岗位规模有限(内置 + 自定义),一次取足够上限,不做分页。 + """ + roles = business_role_repository.list_roles(session, offset=0, limit=1000) + return ok( + [ + { + "id": role.id, + "role_code": role.role_code, + "role_name": role.role_name, + "status": role.status, + } + for role in roles + ] + ) diff --git a/backend/app/api/v1/dashboard.py b/backend/app/api/v1/dashboard.py new file mode 100644 index 0000000..c9d7077 --- /dev/null +++ b/backend/app/api/v1/dashboard.py @@ -0,0 +1,39 @@ +"""工作台接口(M4 ⑧):四区块一次性聚合。 + +准入:管理员/财务/领导(与报表、审批接口一致;FR-033/036 的「全部用户」 +待业务权限清单 #6 答复后再放宽)。只读接口,不写操作日志。 +""" + +from typing import Annotated + +from fastapi import APIRouter, Depends, Query + +from app.api.deps import DbSession, require_roles +from app.core.constants import ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER +from app.core.response import ApiResponse, ok +from app.models import User +from app.schemas.dashboard import DashboardOut +from app.services import dashboard_service + +router = APIRouter(prefix="/dashboard", tags=["工作台"]) + +_DashboardUser = Annotated[ + User, + Depends( + require_roles( + ROLE_ADMIN, ROLE_FINANCE, ROLE_LEADER, message="无权访问工作台" + ) + ), +] + + +@router.get("", response_model=ApiResponse[DashboardOut], summary="工作台四区块") +def get_dashboard( + session: DbSession, + _: _DashboardUser, + month: Annotated[ + str | None, Query(max_length=7, description="统计月份 YYYY-MM,默认当月") + ] = None, +) -> ApiResponse[DashboardOut]: + """一次返回经营总览/待办/风险预警/最近动态(待办与预警实时派生)。""" + return ok(dashboard_service.get_dashboard(session, month)) diff --git a/backend/app/core/month.py b/backend/app/core/month.py new file mode 100644 index 0000000..f4c2570 --- /dev/null +++ b/backend/app/core/month.py @@ -0,0 +1,26 @@ +"""月份参数校验(YYYY-MM)——叶子模块。 + +报表、月结与调整单共用同一套校验:只校验长度会放过 `2026-13` 这类 +非法月份,一路传到 `calendar.monthrange` 才抛 ValueError,最终被全局 +处理器当成服务端异常(500)。月份范围必须在入口处拦住,才能返回 400。 +""" + +import re + +_MONTH_RE = re.compile(r"^\d{4}-(0[1-9]|1[0-2])$") + + +def validate_month(value: str) -> str: + """校验 YYYY-MM 且月份 01-12;非法抛 ValueError(Pydantic 友好)。 + + 年份 0000 也拒绝:`date(0, ...)` 同样会抛 ValueError,且业务上不存在。 + """ + if not _MONTH_RE.match(value) or int(value[:4]) == 0: + raise ValueError("月份格式须为 YYYY-MM(01-12)") + return value + + +def parse_month(value: str) -> tuple[int, int]: + """校验并返回 (year, month),供服务层计算月末等使用。""" + validate_month(value) + return int(value[:4]), int(value[5:7]) diff --git a/backend/app/repositories/dashboard_repository.py b/backend/app/repositories/dashboard_repository.py new file mode 100644 index 0000000..2b4d0cf --- /dev/null +++ b/backend/app/repositories/dashboard_repository.py @@ -0,0 +1,209 @@ +"""工作台仓储:四区块的只读聚合查询;不 commit、不抛业务异常。 + +总览只做 SQL 聚合(SUM/COUNT DISTINCT),不复用报表的 +``department_summary``——后者会加载整月台账并逐团队查人员,适合报表页, +不适合每次打开工作台。 +""" + +from datetime import date, datetime + +from sqlalchemy import exists, func, select +from sqlalchemy.orm import Session + +from app.models import ( + ApprovalOrder, + Business, + BusinessRole, + MonthlySettlement, + PerformanceLedger, + Personnel, + Receipt, + Shipment, +) +from app.models.approval import ( + APPROVAL_STATUS_PENDING_FINANCE, + APPROVAL_STATUS_PENDING_LEADER, +) + +_ACTIVE_SINGLE = (PerformanceLedger.calc_type == 1, PerformanceLedger.is_active == 1) +_PENDING_STATUSES = ( + APPROVAL_STATUS_PENDING_FINANCE, + APPROVAL_STATUS_PENDING_LEADER, +) + + +# ==== 经营绩效总览 ==== + +def sum_month_performance(session: Session, month: str) -> dict: + """当月单笔生效行合计与人员数(拆行求和不失真,人数需 DISTINCT)。""" + return session.execute( + select( + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0).label( + "performance_total" + ), + func.coalesce(func.sum(PerformanceLedger.payable_amount), 0).label("payable_total"), + func.count(func.distinct(PerformanceLedger.personnel_id)).label("person_count"), + ).where( + PerformanceLedger.settlement_month == month, + *_ACTIVE_SINGLE, + ) + ).mappings().one() + + +def performance_trend(session: Session, months: list[str]) -> dict[str, object]: + """近 N 个月绩效合计(按台账归属月)。""" + rows = session.execute( + select( + PerformanceLedger.settlement_month, + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0), + ) + .where( + PerformanceLedger.settlement_month.in_(months), + *_ACTIVE_SINGLE, + ) + .group_by(PerformanceLedger.settlement_month) + ).all() + return dict(rows) + + +def receipt_trend(session: Session, months: list[str]) -> dict[str, object]: + """近 N 个月回款合计(按到账月,含未核算回款——与台账口径不同,页面注明)。""" + 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)) + .group_by(month_expr) + ).all() + return dict(rows) + + +def count_active_managers(session: Session) -> int: + """在职营销经理数(团队数口径,与部门汇总的团队枚举一致)。""" + return int( + session.execute( + select(func.count()) + .select_from(Personnel) + .join(BusinessRole, Personnel.business_role_id == BusinessRole.id) + .where( + BusinessRole.role_code == "sales_manager", + Personnel.status == 1, + ) + ).scalar_one() + ) + + +def get_month_settlement(session: Session, month: str) -> MonthlySettlement | None: + """当月月结头(状态与锁定时间角标)。""" + return session.execute( + select(MonthlySettlement).where(MonthlySettlement.settlement_month == month) + ).scalar_one_or_none() + + +# ==== 待办事项 ==== + +def list_pending_approvals(session: Session, *, limit: int) -> list[ApprovalOrder]: + """待处理审批单(按提交时间升序——最久的排前面)。""" + return list( + session.execute( + select(ApprovalOrder) + .where(ApprovalOrder.status.in_(_PENDING_STATUSES)) + .order_by(ApprovalOrder.create_time.asc(), ApprovalOrder.id.asc()) + .limit(limit) + ).scalars() + ) + + +def count_pending_approvals(session: Session) -> int: + return int( + session.execute( + select(func.count()) + .select_from(ApprovalOrder) + .where(ApprovalOrder.status.in_(_PENDING_STATUSES)) + ).scalar_one() + ) + + +def list_unassigned_receipts(session: Session, *, limit: int) -> list: + """回款未指定发货批次(shipment_id IS NULL,核算前置缺失)。""" + return list( + session.execute( + select( + Receipt, + Business.business_no.label("business_no"), + ) + .outerjoin(Business, Business.id == Receipt.business_id) + .where(Receipt.shipment_id.is_(None)) + .order_by(Receipt.receipt_date.asc(), Receipt.id.asc()) + .limit(limit) + ).all() + ) + + +def count_unassigned_receipts(session: Session) -> int: + return int( + session.execute( + select(func.count()) + .select_from(Receipt) + .where(Receipt.shipment_id.is_(None)) + ).scalar_one() + ) + + +# ==== 风险预警 ==== + +def list_overdue_shipments(session: Session, *, cutoff: date, limit: int) -> list: + """发货后超过阈值且该批次无任何回款的发货单(回款逾期规则)。""" + has_receipt = exists().where(Receipt.shipment_id == Shipment.id) + return list( + session.execute( + select( + Shipment, + Business.business_no.label("business_no"), + ) + .outerjoin(Business, Business.id == Shipment.business_id) + .where(Shipment.ship_date <= cutoff, ~has_receipt) + .order_by(Shipment.ship_date.asc()) + .limit(limit) + ).all() + ) + + +def count_overdue_shipments(session: Session, *, cutoff: date) -> int: + has_receipt = exists().where(Receipt.shipment_id == Shipment.id) + return int( + session.execute( + select(func.count()) + .select_from(Shipment) + .where(Shipment.ship_date <= cutoff, ~has_receipt) + ).scalar_one() + ) + + +def list_overdue_approvals( + session: Session, *, cutoff: datetime, limit: int +) -> list[ApprovalOrder]: + """提交后超过阈值仍未处理的审批单(审批超时规则)。""" + return list( + session.execute( + select(ApprovalOrder) + .where( + ApprovalOrder.status.in_(_PENDING_STATUSES), + ApprovalOrder.create_time < cutoff, + ) + .order_by(ApprovalOrder.create_time.asc()) + .limit(limit) + ).scalars() + ) + + +def count_overdue_approvals(session: Session, *, cutoff: datetime) -> int: + return int( + session.execute( + select(func.count()) + .select_from(ApprovalOrder) + .where( + ApprovalOrder.status.in_(_PENDING_STATUSES), + ApprovalOrder.create_time < cutoff, + ) + ).scalar_one() + ) diff --git a/backend/app/schemas/dashboard.py b/backend/app/schemas/dashboard.py new file mode 100644 index 0000000..bfad0d1 --- /dev/null +++ b/backend/app/schemas/dashboard.py @@ -0,0 +1,125 @@ +"""工作台的请求/响应模型(M4 ⑧)。 + +四区块一次返回(前端一次请求渲染整页,避免 6-10 次往返): +经营绩效总览 / 待办事项 / 风险预警 / 最近动态。金额一律 Decimal +(序列化为字符串),前端不做数值运算。 + +待办与预警为**查询时实时派生**(不落库、不引调度器,见计划决策); +⑥ T+ 同步未建前,同步相关条目用 `sync_available=false` 显式降级, +不返回伪造的 0 条。 +""" + +from datetime import date, datetime +from decimal import Decimal + +from pydantic import BaseModel, Field + + +class TrendPoint(BaseModel): + """近 6 个月趋势点(绩效按台账归属月,回款按到账月)。""" + + month: str + performance_total: Decimal | None = None + receipt_total: Decimal | None = None + + +class SettlementBrief(BaseModel): + """当月月结状态(工作台总览角标)。""" + + exists: bool = False + status: int | None = Field(default=None, description="1草稿 2待审批 3已审定 4已驳回") + total_performance: Decimal | None = None + total_payable: Decimal | None = None + locked_at: datetime | None = None + + +class OverviewBlock(BaseModel): + """经营绩效总览(FR-033):口径与台账/月结一致。""" + + month: str + performance_total: Decimal | None = Field(default=None, description="当月单笔绩效合计") + payable_total: Decimal | None = Field(default=None, description="当月应付绩效合计") + person_count: int = Field(default=0, description="当月有台账的人员数(拆行去重)") + team_count: int = Field(default=0, description="在职营销经理数(团队数)") + settlement: SettlementBrief + trend: list[TrendPoint] = Field(default_factory=list, description="近 6 个月") + note: str = Field(default="", description="口径说明(页面展示)") + + +class ApprovalTodoItem(BaseModel): + """待审批条目。""" + + order_id: int + order_no: str + order_type: int + submitter_name: str | None = None + create_time: datetime + + +class ReceiptTodoItem(BaseModel): + """回款未指定发货批次条目。""" + + receipt_id: int + receipt_batch_no: str + business_id: int + business_no: str | None = None + amount: Decimal + receipt_date: date + + +class TodoBlock(BaseModel): + """待办事项(FR-034,澄清 #11 起步范围)。""" + + pending_approval_total: int = 0 + pending_approvals: list[ApprovalTodoItem] = Field(default_factory=list) + unassigned_receipt_total: int = 0 + unassigned_receipts: list[ReceiptTodoItem] = Field(default_factory=list) + sync_available: bool = Field(default=False, description="⑥ 同步模块是否已启用") + sync_failure_total: int = 0 + + +class WarningItem(BaseModel): + """风险预警条目(实时派生,不改变业务数据)。""" + + code: str = Field(description="规则编码:receipt_overdue / approval_timeout") + level: str = Field(description="级别:warning / danger") + object_type: str + object_id: int + title: str + detail: str + suggest: str + link: str | None = Field(default=None, description="前端跳转路径") + + +class WarningBlock(BaseModel): + """风险预警(FR-035,首批两条可算规则;同步规则待 ⑥)。""" + + enabled: bool = True + receipt_overdue_days: int = 90 + approval_timeout_days: int = 3 + items: list[WarningItem] = Field(default_factory=list) + receipt_overdue_total: int = 0 + approval_timeout_total: int = 0 + sync_available: bool = False + + +class ActivityItem(BaseModel): + """最近动态条目(来源 operation_logs,只读聚合)。""" + + id: int + module: str + action: str + summary: str + username: str | None = None + success: int + create_time: datetime + + +class DashboardOut(BaseModel): + """工作台四区块一次性响应。""" + + month: str + overview: OverviewBlock + todos: TodoBlock + warnings: WarningBlock + activities: list[ActivityItem] = Field(default_factory=list) diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py new file mode 100644 index 0000000..4f6ddef --- /dev/null +++ b/backend/app/services/dashboard_service.py @@ -0,0 +1,245 @@ +"""工作台服务(M4 ⑧):四区块实时聚合编排。 + +设计决策(2026-10-19): +- **只读**:不落库、不写审计(读操作不入操作日志);总览只做 SQL 聚合, + 不复用报表的部门汇总(后者会加载整月台账,过重) +- **待办与预警实时派生**:不建待办/预警表、不引调度器;处置动作本身 + 已有操作日志可追溯。与 FR-035「预警产生/消除留痕」的差距记录在 + 计划开放项,待业务确认是否物化 +- **⑥ 同步模块未启用**:同步失败待办、同步连续失败预警以 + ``sync_available=false`` 显式降级,不返回伪造的 0 条 +- 预警阈值走参数体系(``warning.*``),一次请求只解析一次快照 +""" + +from datetime import date, datetime, timedelta +from decimal import Decimal + +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.query_utils import LogFilters + +# 工作台只展示摘要,完整列表跳转对应管理页 +_TODO_LIMIT = 5 +_WARNING_LIMIT = 20 +_ACTIVITY_LIMIT = 20 +_TREND_MONTHS = 6 + +_ORDER_TYPE_LABELS = {1: "二次分配方案", 2: "月度结算", 3: "调整单"} + + +def get_dashboard(session: Session, month: str | None = None) -> dict: + """四区块一次性出数(前端一次请求渲染整页)。""" + resolved = _normalize_month(month) + warning_params = _warning_params(session) + return { + "month": resolved, + "overview": _overview(session, resolved), + "todos": _todos(session), + "warnings": _warnings(session, warning_params), + "activities": _activities(session), + } + + +def _normalize_month(month: str | None) -> str: + """未传月份按当月;非法月份在入口拦成 400(避免 500)。""" + if not month: + today = date.today() + return f"{today.year:04d}-{today.month:02d}" + try: + return validate_month(month) + except ValueError as exc: + raise BadRequestError(str(exc)) from exc + + +def _shift_month(month: str, delta: int) -> str: + """YYYY-MM 平移 delta 个月(用于近 N 个月窗口)。""" + year, mon = int(month[:4]), int(month[5:7]) + index = year * 12 + (mon - 1) + delta + return f"{index // 12:04d}-{index % 12 + 1:02d}" + + +def _trend_months(month: str) -> list[str]: + """窗口为「当月及之前共 6 个月」,升序返回便于前端画图。""" + return [_shift_month(month, offset) for offset in range(-(_TREND_MONTHS - 1), 1)] + + +def _overview(session: Session, month: str) -> dict: + """经营绩效总览(FR-033):口径与台账/月结一致。""" + sums = dashboard_repository.sum_month_performance(session, month) + 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) + return { + "month": month, + "performance_total": _dec(sums["performance_total"]), + "payable_total": _dec(sums["payable_total"]), + "person_count": int(sums["person_count"]), + "team_count": dashboard_repository.count_active_managers(session), + "settlement": { + "exists": settlement is not None, + "status": settlement.status if settlement else None, + "total_performance": settlement.total_performance if settlement else None, + "total_payable": settlement.total_payable if settlement else None, + "locked_at": settlement.locked_at if settlement else None, + }, + "trend": [ + { + "month": item, + "performance_total": _dec(performance.get(item)), + "receipt_total": _dec(receipts.get(item)), + } + for item in months + ], + "note": "绩效按台账归属月(单笔生效行,二次分摊拆行求和不失真);回款按到账月(含未核算回款);不含调整单", + } + + +def _todos(session: Session) -> dict: + """待办事项(FR-034,澄清 #11 起步范围)。""" + orders = dashboard_repository.list_pending_approvals(session, limit=_TODO_LIMIT) + receipts = dashboard_repository.list_unassigned_receipts(session, limit=_TODO_LIMIT) + return { + "pending_approval_total": dashboard_repository.count_pending_approvals(session), + "pending_approvals": [ + { + "order_id": order.id, + "order_no": order.order_no, + "order_type": order.order_type, + "submitter_name": order.submitter_name, + "create_time": order.create_time, + } + for order in orders + ], + "unassigned_receipt_total": dashboard_repository.count_unassigned_receipts(session), + "unassigned_receipts": [ + { + "receipt_id": row[0].id, + "receipt_batch_no": row[0].receipt_batch_no, + "business_id": row[0].business_id, + "business_no": row.business_no, + "amount": row[0].amount, + "receipt_date": row[0].receipt_date, + } + for row in receipts + ], + # ⑥ T+ 同步未建:显式降级,不伪造「无失败」 + "sync_available": False, + "sync_failure_total": 0, + } + + +def _warning_params(session: Session) -> dict: + """预警参数一次解析(阈值参数化,澄清 #10)。""" + from app.services.param_service import parse_fraction, resolve_snapshot + + snapshot = resolve_snapshot(session, date.today()) + return { + "enabled": snapshot.get("warning.enabled") == "1", + "receipt_overdue_days": int(parse_fraction(snapshot.get("warning.receipt.overdue.days"))), + "approval_timeout_days": int( + parse_fraction(snapshot.get("warning.approval.timeout.days")) + ), + } + + +def _warnings(session: Session, params: dict) -> dict: + """风险预警(FR-035):首批两条可算规则;同步规则待 ⑥。""" + if not params["enabled"]: + return { + "enabled": False, + "receipt_overdue_days": params["receipt_overdue_days"], + "approval_timeout_days": params["approval_timeout_days"], + "items": [], + "receipt_overdue_total": 0, + "approval_timeout_total": 0, + "sync_available": False, + } + + receipt_cutoff = date.today() - timedelta(days=params["receipt_overdue_days"]) + approval_cutoff = datetime.now() - timedelta(days=params["approval_timeout_days"]) + shipments = dashboard_repository.list_overdue_shipments( + session, cutoff=receipt_cutoff, limit=_WARNING_LIMIT + ) + orders = dashboard_repository.list_overdue_approvals( + session, cutoff=approval_cutoff, limit=_WARNING_LIMIT + ) + + items = [ + { + "code": "receipt_overdue", + "level": "danger", + "object_type": "shipment", + "object_id": row[0].id, + "title": f"发货批次 {row[0].shipment_no} 逾期未回款", + "detail": ( + f"业务 {row.business_no or row[0].business_id} 发货日 {row[0].ship_date}," + f"超过 {params['receipt_overdue_days']} 天无回款记录" + ), + "suggest": "联系客户催收,或核对回款批次指定关系", + "link": "/business/shipments", + } + for row in shipments + ] + items += [ + { + "code": "approval_timeout", + "level": "warning", + "object_type": "approval_order", + "object_id": order.id, + "title": f"审批单 {order.order_no} 超时未处理", + "detail": ( + f"{_ORDER_TYPE_LABELS.get(order.order_type, '审批')}由 " + f"{order.submitter_name or '—'} 提交于 {order.create_time:%Y-%m-%d %H:%M}," + f"已超过 {params['approval_timeout_days']} 天" + ), + "suggest": "在审批管理中复核或退回", + "link": "/system/approvals", + } + for order in orders + ] + 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 + ), + "sync_available": False, + } + + +def _activities(session: Session) -> list[dict]: + """最近动态(FR-036):以操作日志为唯一来源(写操作即留痕)。""" + logs = operation_log_repository.list_logs( + session, + offset=0, + limit=_ACTIVITY_LIMIT, + filters=LogFilters(), + ) + return [ + { + "id": log.id, + "module": log.module, + "action": log.action, + "summary": log.summary, + "username": log.username, + "success": log.success, + "create_time": log.create_time, + } + for log in logs + ] + + +def _dec(value) -> Decimal | None: + """SQL 聚合结果转 Decimal(SUM 可能返回 int,None 原样返回)。""" + if value is None: + return None + return value if isinstance(value, Decimal) else Decimal(str(value)) diff --git a/backend/app/services/param_registry.py b/backend/app/services/param_registry.py index 48305cc..4184fb8 100644 --- a/backend/app/services/param_registry.py +++ b/backend/app/services/param_registry.py @@ -314,6 +314,54 @@ PARAM_REGISTRY: dict[str, ParamSpec] = { ), sort_order=20, ), + # ===== 预警规则(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」)===== + # 首批三条中「同步连续失败」依赖 ⑥ T+ 同步,模块未建前不设参数、不产生条目 + ParamSpec( + code="warning.enabled", + name="风险预警总开关", + group="预警规则", + value_type="BOOL", + unit=None, + dimension_type="NONE", + options=None, + default_value="1", + value_min=None, + value_max=None, + description="工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)", + sort_order=21, + ), + ParamSpec( + code="warning.receipt.overdue.days", + name="回款逾期天数阈值", + group="预警规则", + value_type="INT", + unit="天", + dimension_type="NONE", + options=None, + default_value="90", + value_min="1", + value_max=None, + description=( + "发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)" + ), + sort_order=22, + ), + ParamSpec( + code="warning.approval.timeout.days", + name="审批超时天数阈值", + group="预警规则", + value_type="INT", + unit="天", + dimension_type="NONE", + options=None, + default_value="3", + value_min="1", + value_max=None, + description=( + "待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)" + ), + sort_order=23, + ), ] } diff --git a/backend/tests/README.md b/backend/tests/README.md index fbe5481..f4513d9 100644 --- a/backend/tests/README.md +++ b/backend/tests/README.md @@ -140,12 +140,24 @@ uv run pytest tests/integration -q # 仅集成 | 文件 | 守什么 | 用例数 | | --- | --- | --- | -| `tests/integration/test_reports.py` | 个人明细与台账逐笔一致 + **pageSize=1 时 summary 仍为全筛选合计**;部门汇总团队基数=成员绩效合计、12.5% 分成按参数、无成员经理出 0 值行;回款统计 8 档桶恒在、空桶为 0、**二次分摊拆行时回款金额只计一次**(防翻倍)、绩效合计=台账;业务统计排除经理聚合行(生成月结后绩效不被整团队聚合额污染);导出 xlsx 可解析且含「财务复核/领导签字」栏 + 操作日志留痕(module=报表统计/action=导出);普通用户 403 且文案「无权访问报表统计」 | 7 | -| `tests/unit/test_report_helpers.py` | 区间分桶边界(NULL/负值/0/1/30/…/361+)与 8 档编码唯一;客户类型、产品类别标签覆盖全部常量枚举(防新增枚举后报表少一类);Excel 模板纯构建含页眉/签字栏、空数据提示行、金额为数值单元格且两位格式、合计行=明细之和、文本公式注入前置单引号、回款统计两个维度对比 sheet、业务统计状态转中文 | 11 | +| `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/unit/test_month_validation.py` | 非法月份(`2026-13`/`2026-00`/`0000-01` 等)在报表、月结、调整单 schema 入口即拒绝;可选月份空串规范为 None | 17 | -运行:`uv run pytest tests/integration/test_reports.py -q` → **7 passed**(2026-10-19);`uv run pytest tests/unit/test_report_helpers.py -q` → **11 passed**(2026-10-19);全量回归见运行记录。 +运行:`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)。 -**待补**:e2e `frontend/e2e/report_flow.py`(四页筛选/导出下载/打印视图,需 `E2E_ADMIN_PASSWORD`)。 +**e2e**:`frontend/e2e/report_flow.py` 已补回款多桶同时展开、部门下钻筛选生效、打印截断提示、部门汇总打印五块结构;需 `E2E_ADMIN_PASSWORD` 与前后端在跑。 + +### 工作台(M4 ⑧,2026-10-19 完成) + +| 文件 | 守什么 | 用例数 | +| --- | --- | --- | +| `tests/integration/test_dashboard.py` | 权限 403 且文案「无权访问工作台」;总览绩效合计与台账一致 + 非法月份 400;待办(月结提交→待审批命中、回款未指定批次命中、**同步未建显式降级**);风险预警(审批超时命中、**参数关闭后不产生条目**);最近动态来源操作日志 | 5 | +| `tests/unit/test_param_registry.py`(改) | 注册表 19→**22 项**(V10 预警规则三项);编码集合与迁移种子一致 | 改 | + +运行:`uv run pytest tests/integration/test_dashboard.py -q` → **5 passed**(2026-10-19)。 + +**实施中发现并修复**:`alembic upgrade --sql` 在 Windows 控制台按 GBK 输出,重定向生成的 `V10__*.sql` 被 `test_schema_sync` 按 UTF-8 读取时抛 `UnicodeDecodeError`——导出命令须加 `PYTHONIOENCODING=utf-8 PYTHONUTF8=1`(已写入 backend/README)。 ## 三、运行记录 @@ -167,6 +179,8 @@ uv run pytest tests/integration -q # 仅集成 | 2026-10-19 | M3 定点(calc/settlements) | 34 | ✅ 通过(55s) | **code-review P0+P1 修复中**:经理聚合比例/调整单即生效/进度登记重算 400/财务节点不提前锁定/锁定拦截三路径/回填与聚合落库/月末取参(+新增 `test_settlement_helpers` 5 例、`test_calc_helpers` 4 例锁定 `_q2` HALF_UP) | | 2026-10-19 | 全量回归 | 780 | ✅ 780 passed(8:53) | **M3 code-review 21 项修复后全量回归**(P0 算错钱/不可用 6 项 → P1 锁定不变量与双轨漂移 7 项 → P2 健壮性 6 项 → calc_receipt 重构;新增回归约 20 例,含跨会话并入的 #12 聚合基数开关用例)。type-check 绿;e2e calc_settlement_flow 仍待补 | | 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 | 单元全量 + 报表定点 | 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` 运行 | @@ -174,7 +188,13 @@ uv run pytest tests/integration -q # 仅集成 | 日期 | 缺陷 | 影响 | 处理 | | --- | --- | --- | --- | -| 2026-10-19 | 分摊负责人份额按余额百分数乘全额导致行合计丢 q2 尾差 | 拆 30%+40% 后负责人 100−70=30% 再乘未 q2 全额(39.1111×0.30=11.7333),行合计 39.10 ≠ 39.11——**每笔分摊账丢 1 分**,守恒破坏(金标准用例 `行合计==业务级全额` 抓住) | 负责人行恒走余额公式(q2 全额 −∑参与人 q2 份额);余额百分数仅作快照展示不参与计算 | +| 2026-10-19 | 部门汇总导出/打印只展平成员行 | 团队分成、全公司 5% 块、未分配人员、零成员团队全部缺失——**签字稿合计小于应发**,财务按签字稿复核会漏发经理分成与未分配绩效 | 导出改为单 sheet 分块签字稿(团队/公司/对账/成员/未分配)+ 打印五块;应发公式与对账块由后端 Decimal 计算 | +| 2026-10-19 | `_member_rows` 直接 `member_map[pid]` 索引 | 无台账行的营销经理自身挂上级时 KeyError → **部门汇总接口与导出 500**(当月完全无台账也会触发) | 归属只从 member_map/adjustments 生成;`manager_role` 覆盖所有成员引用的上级 | +| 2026-10-19 | 生效调整单只并入有台账人员 | 只有调整单、当月无回款的人被静默丢弃——补发/扣减金额不出现,**按报表发放会少发** | 调整单人员补零值成员后参与团队/未分配归属;每人调整净额只计一次 | +| 2026-10-19 | 经理个人户成员行取该人全部绩效 | 团队基数只含个人户行,成员行却含普通行——**基数≠成员合计,同一笔业绩在成员行与未分配重复** | 普通行与个人户分行记账;经理成员行只取个人户金额,普通行留在上级团队/未分配 | +| 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-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")` | @@ -229,6 +249,6 @@ uv run pytest tests/integration -q # 仅集成 | 业务数据管理 | ✅ 后端已完成 | 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` 实跑 | > 系统管理模块(用户/角色/日志查询)已于 2026-10-10 完成并补齐用例,见上表。 diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index d84f14d..5a16462 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -190,14 +190,14 @@ def _purge_test_data() -> None: # 审批单无 t_ 前缀可用,按 AP 编号前缀全量清(steps 外键 CASCADE 随删; # ref_id 固化无外键,业务已在上一步删除,无悬挂影响) session.execute(delete(ApprovalOrder).where(ApprovalOrder.order_no.like("AP%"))) - # 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 25 + # 开关参数恢复种子基线:测试提交的变更批次条目 id 均大于种子终值 28 # (V3 十九条 + V6 两条 + V7 调整单一条 + V8 经理基数开关一条;V8 另 - # 插入两个审批开关的 10-19 生效行 id 23/24,均属种子),删新条目与 - # 新批次后解析回退到种子值 - stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 25)).scalars()) + # 插入两个审批开关的 10-19 生效行 id 23/24,V10 插入预警规则参数 + # id 26/27/28,均属种子),删新条目与新批次后解析回退到种子值 + stray_values = list(session.execute(select(ParamValue).where(ParamValue.id > 28)).scalars()) if stray_values: stray_batches = {v.batch_id for v in stray_values} - session.execute(delete(ParamValue).where(ParamValue.id > 25)) + session.execute(delete(ParamValue).where(ParamValue.id > 28)) session.execute(delete(ParamChangeBatch).where(ParamChangeBatch.id.in_(stray_batches))) # 系统设置恢复种子单行(test_settings 会改公司名/传 logo——残留会让 # 后续 e2e 的品牌断言在污染状态下运行);uploads 下的测试 logo 一并删 diff --git a/backend/tests/integration/test_dashboard.py b/backend/tests/integration/test_dashboard.py new file mode 100644 index 0000000..6a5c5a3 --- /dev/null +++ b/backend/tests/integration/test_dashboard.py @@ -0,0 +1,232 @@ +"""工作台集成测试(M4 ⑧)。 + +守的口径: +- 权限:普通用户 403 且文案「无权访问工作台」 +- 总览:绩效合计/人数与台账一致;月份参数非法 400 +- 待办:月结提交后出现待审批;回款未指定批次可命中;同步未建时显式降级 +- 风险预警:审批超时命中、参数关闭后不产生条目、阈值参数化生效 +- 最近动态:来源操作日志(写操作可见) + +待办与预警为实时派生(不落表),测试只验证查询结果,不清理派生数据。 +""" + +import uuid +from datetime import date, datetime, timedelta +from decimal import Decimal + +import pytest +from fastapi.testclient import TestClient + +pytestmark = pytest.mark.integration + +_DASHBOARD = "/api/v1/dashboard" +_PERSONNEL = "/api/v1/base/personnel" +_BUSINESSES = "/api/v1/business/businesses" +_SHIPMENTS = "/api/v1/business/shipments" +_RECEIPTS = "/api/v1/business/receipts" +_CALC = "/api/v1/calc" +_SETTLEMENTS = "/api/v1/settlements" +_PARAMS = "/api/v1/system/params" + + +def _role_id(code: str) -> int: + from sqlalchemy import select + + from app.core.db import get_session_factory + from app.models import BusinessRole + + factory = get_session_factory() + with factory() as session: + return int( + session.execute( + select(BusinessRole.id).where( + BusinessRole.role_code == code, BusinessRole.status == 1 + ) + ).scalar_one() + ) + + +def _person(client: TestClient, headers: dict) -> dict: + resp = client.post( + _PERSONNEL, + json={ + "personnel_name": f"t_工作台_{uuid.uuid4().hex[:8]}", + "business_role_id": _role_id("salesperson"), + "salary_standard": "3000.00", + }, + headers=headers, + ) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def _chain(client: TestClient, headers: dict, *, assign_shipment: bool = True) -> dict: + """客户/业务(100×100,成本 3000)→ 发货(60 天前)→ 回款 → 核算。""" + from tests.integration.factories import business_item, business_payload, customer + + cust = customer(client, headers) + person = _person(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=60)).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 + shipment = resp.json()["data"] + + payload = { + "business_id": business["id"], + "receipt_date": date.today().isoformat(), + "amount": "10000.00", + } + if assign_shipment: + payload["shipment_id"] = shipment["id"] + resp = client.post(_RECEIPTS, json=payload, headers=headers) + assert resp.status_code == 200, resp.text + receipt = resp.json()["data"] + if assign_shipment: + resp = client.post(f"{_CALC}/receipts/{receipt['id']}/calc", headers=headers) + assert resp.status_code == 200, resp.text + return {"business": business, "person": person, "receipt": receipt} + + +def _month() -> str: + return date.today().strftime("%Y-%m") + + +def _dashboard(client: TestClient, headers: dict, month: str | None = None) -> dict: + params = {"month": month} if month else {} + resp = client.get(_DASHBOARD, params=params, headers=headers) + assert resp.status_code == 200, resp.text + return resp.json()["data"] + + +def test_权限_普通用户403文案(client: TestClient, normal: dict): + resp = client.get(_DASHBOARD, headers=normal["headers"]) + assert resp.status_code == 403 + assert resp.json()["message"] == "无权访问工作台" + + +def test_总览_绩效合计与台账一致且非法月份400(client: TestClient, admin: dict): + _chain(client, admin["headers"]) + data = _dashboard(client, admin["headers"], _month()) + + from sqlalchemy import func, select + + from app.core.db import get_session_factory + from app.models import PerformanceLedger + + factory = get_session_factory() + with factory() as session: + expected = Decimal( + str( + session.execute( + select( + func.coalesce(func.sum(PerformanceLedger.performance_amount), 0) + ).where( + PerformanceLedger.settlement_month == _month(), + PerformanceLedger.calc_type == 1, + PerformanceLedger.is_active == 1, + ) + ).scalar_one() + ) + ) + assert Decimal(str(data["overview"]["performance_total"])) == expected + assert data["overview"]["person_count"] >= 1 + assert len(data["overview"]["trend"]) == 6 + + resp = client.get(_DASHBOARD, params={"month": "2026-13"}, headers=admin["headers"]) + assert resp.status_code == 400 + + +def test_待办_待审批与未指定批次与同步降级(client: TestClient, admin: dict): + # 先造一笔已核算业务(月结生成要求有可结算记录),再补一笔未指定批次的回款 + _chain(client, admin["headers"], assign_shipment=True) + ctx = _chain(client, admin["headers"], assign_shipment=False) + # 月结提交 → 产生待审批单 + 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 + + data = _dashboard(client, admin["headers"]) + assert data["todos"]["pending_approval_total"] >= 1 + assert any( + item["order_type"] == 2 for item in data["todos"]["pending_approvals"] + ) + assert data["todos"]["unassigned_receipt_total"] >= 1 + assert data["todos"]["unassigned_receipts"][0]["receipt_id"] == ctx["receipt"]["id"] + # ⑥ 未建:显式降级 + assert data["todos"]["sync_available"] is False + + +def test_预警_审批超时命中且参数关闭后不产生(client: TestClient, admin: dict): + _chain(client, admin["headers"]) + resp = client.post( + f"{_SETTLEMENTS}/generate", json={"month": _month()}, headers=admin["headers"] + ) + settlement_id = resp.json()["data"]["id"] + client.post(f"{_SETTLEMENTS}/{settlement_id}/submit", headers=admin["headers"]) + + # 把待审批单提交时间改到 4 天前(阈值默认 3 天) + from sqlalchemy import select, update + + from app.core.db import get_session_factory + from app.models import ApprovalOrder + + factory = get_session_factory() + with factory() as session: + session.execute( + update(ApprovalOrder) + .where(ApprovalOrder.status.in_((1, 2))) + .values(create_time=datetime.now() - timedelta(days=4)) + ) + session.commit() + + data = _dashboard(client, admin["headers"]) + assert data["warnings"]["enabled"] is True + assert any(item["code"] == "approval_timeout" for item in data["warnings"]["items"]) + + # 关闭预警开关 → 不产生条目 + resp = client.post( + f"{_PARAMS}/changes", + json={ + "change_type": 1, + "remark": "e2e 工作台测试:关闭风险预警", + "items": [{"param_code": "warning.enabled", "dimension_value": "", "value": "0"}], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + data = _dashboard(client, admin["headers"]) + assert data["warnings"]["enabled"] is False + assert data["warnings"]["items"] == [] + + +def test_动态_来源操作日志(client: TestClient, admin: dict): + _chain(client, admin["headers"]) + data = _dashboard(client, admin["headers"]) + assert data["activities"], "工作台动态为空:写操作应产生操作日志" + assert {"module", "action", "summary", "create_time"} <= set(data["activities"][0]) diff --git a/backend/tests/integration/test_param_management.py b/backend/tests/integration/test_param_management.py index d5d2c52..abca972 100644 --- a/backend/tests/integration/test_param_management.py +++ b/backend/tests/integration/test_param_management.py @@ -86,10 +86,10 @@ def _snapshot_value(client: TestClient, admin: dict, param_code: str, *, as_of: class TestCatalog: """参数目录:种子完整性与时点化回溯。""" - def test_目录共19项且覆盖八个分组(self, client: TestClient, admin: dict) -> None: - """V7 起含调整单审批开关(16 计提项 + 3 BOOL 开关)。""" + def test_目录共22项且覆盖九个分组(self, client: TestClient, admin: dict) -> None: + """V10 起含工作台预警规则三项(16 计提项 + 3 BOOL 开关 + 3 预警项)。""" items = client.get(_PARAMS, headers=admin["headers"]).json()["data"] - assert len(items) == 19 + assert len(items) == 22 groups = {item["param_group"] for item in items} assert groups == { "业务员个人户", @@ -100,6 +100,7 @@ class TestCatalog: "产品阈值", "月工资口径", "审批开关", + "预警规则", } def test_企业户比例按到账日回溯_2千分3千分双种子(self, client: TestClient, admin: dict) -> None: @@ -444,7 +445,7 @@ class TestPermission: headers = auth_headers(user) resp = client.get(_PARAMS, headers=headers) assert resp.status_code == 200 - assert len(resp.json()["data"]) == 19 + assert len(resp.json()["data"]) == 22 def test_未登录访问目录_403(self, client: TestClient) -> None: # LoginUser 依赖无令牌即拒(401 语义归登录态失效,此处由依赖抛 403/401 diff --git a/backend/tests/unit/test_month_validation.py b/backend/tests/unit/test_month_validation.py new file mode 100644 index 0000000..f4af415 --- /dev/null +++ b/backend/tests/unit/test_month_validation.py @@ -0,0 +1,55 @@ +"""月份校验单元测试。 + +守的口径:报表、月结、调整单入口都必须拦住 `2026-13`、`0000-01` 这类 +非法月份。只校验长度会让 ValueError 一路传到 `calendar.monthrange`, +最终变成 500 而不是参数错误 400。 +""" + +import pytest +from pydantic import ValidationError + +from app.core.month import parse_month, validate_month +from app.schemas.report import DepartmentSummaryQuery, PersonalDetailQuery +from app.schemas.settlement import AdjustmentCreateIn, SettlementGenerateIn + + +class Test月份校验: + """合法月份通过,非法月份在入口即拒绝。""" + + @pytest.mark.parametrize("month", ["2026-01", "2026-12", "2000-06"]) + def test_合法月份_通过(self, month: str): + assert validate_month(month) == month + year, mon = parse_month(month) + assert (year, mon) == (int(month[:4]), int(month[5:7])) + + @pytest.mark.parametrize( + "month", + ["2026-13", "2026-00", "2026-1", "2026-1a", "0000-01", "abc", "2026/10"], + ) + def test_非法月份_抛ValueError(self, month: str): + with pytest.raises(ValueError): + validate_month(month) + + @pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"]) + def test_月结与调整单_schema拒绝非法月份(self, month: str): + with pytest.raises(ValidationError): + SettlementGenerateIn(month=month) + with pytest.raises(ValidationError): + AdjustmentCreateIn( + settlement_month=month, + personnel_id=1, + direction=1, + amount="100.00", + reason="测试", + ) + + @pytest.mark.parametrize("month", ["2026-13", "2026-00", "0000-01"]) + def test_报表查询_schema拒绝非法月份(self, month: str): + with pytest.raises(ValidationError): + DepartmentSummaryQuery(settlement_month=month) + with pytest.raises(ValidationError): + PersonalDetailQuery(settlement_month=month) + + def test_报表可选月份_空串规范为None(self): + """axios 未剔除空串时不能误报格式错误。""" + assert PersonalDetailQuery(settlement_month="").settlement_month is None diff --git a/backend/tests/unit/test_param_registry.py b/backend/tests/unit/test_param_registry.py index d389410..f8e9a9d 100644 --- a/backend/tests/unit/test_param_registry.py +++ b/backend/tests/unit/test_param_registry.py @@ -18,8 +18,9 @@ from app.services.param_registry import ( validate_and_normalize, ) -# 与 db/migration 迁移种子保持一致的 19 项编码(V3 十六项 + V6 审批开关两项 -# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12) +# 与 db/migration 迁移种子保持一致的 22 项编码(V3 十六项 + V6 审批开关两项 +# + V7 调整单开关;V8 撤试用期统一工资参数 #16、增经理个人户基数开关 #12; +# V10 增工作台预警规则三项 #10) EXPECTED_CODES = { "salesperson.rate.start", "salesperson.rate.slope", @@ -40,14 +41,17 @@ EXPECTED_CODES = { "approval.monthly_settlement.enabled", "approval.adjustment.enabled", "manager.personal.included", + "warning.enabled", + "warning.receipt.overdue.days", + "warning.approval.timeout.days", } class TestRegistry: """注册表元数据完整性(与迁移种子同源)。""" - def test_注册表共19项(self) -> None: - assert len(PARAM_REGISTRY) == 19 + def test_注册表共22项(self) -> None: + assert len(PARAM_REGISTRY) == 22 def test_编码集合与迁移种子一致(self) -> None: assert set(PARAM_REGISTRY) == EXPECTED_CODES diff --git a/db/init/init-dev.sql b/db/init/init-dev.sql index 124a58b..d6ddf1e 100644 --- a/db/init/init-dev.sql +++ b/db/init/init-dev.sql @@ -455,7 +455,11 @@ INSERT INTO param_definitions (id, param_code, param_name, param_group, value_ty (17, 'approval.secondary_allocation.enabled', '二次分配审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '0=二次分配方案提交即生效(系统自动通过全节点留痕,留痕不断链);1=提交后进入财务复核→领导审核审批流。默认开启(澄清 #5 业务答复 2026-10-19),业务可关闭', 17), (18, 'approval.monthly_settlement.enabled', '月度结算审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '月度结算审批链开关(④ 月结提交时消费):0=结算生成即生效,1=走财务复核→领导审核审批流。默认开启(澄清 #7 业务答复 2026-10-19,月结涉及工资发放从严),业务可关闭', 18), (19, 'approval.adjustment.enabled', '调整单审批开关', '审批开关', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '0=调整单提交即生效(系统自动通过留痕);1=走财务复核→领导审核审批流。调整单是已锁定月份补发/扣减的唯一通道(澄清 #9:走同一审批链,默认值业务未明确,暂按直接生效,业务可开启)', 19), -(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20); +(20, 'manager.personal.included', '经理个人户计入聚合基数', '经理分成', 'BOOL', NULL, 'NONE', NULL, '0', NULL, NULL, '经理 12.5%+5% 聚合时是否计入经理本人名下经理个人户业绩(澄清 #12 业务答复 2026-10-19:作为参数配置、手动调整是否纳入):0=不计入(默认,暂口径) 1=计入', 20), +-- 预警规则参数(M4 ⑧ 工作台,澄清 #10「做成参数化规则便于调整」) +(21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21), +(22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22), +(23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23); INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (1, 1, 'salesperson', '2026-02-17', '10', 1), @@ -481,7 +485,11 @@ INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_t (20, 17, '', '2026-01-01', '1', 1), (21, 18, '', '2026-01-01', '1', 1), (22, 19, '', '2026-01-01', '0', 1), -(25, 20, '', '2026-01-01', '0', 1); +(25, 20, '', '2026-01-01', '0', 1), +-- 预警规则参数生效值(M4 ⑧) +(26, 21, '', '2026-10-19', '1', 1), +(27, 22, '', '2026-10-19', '90', 1), +(28, 23, '', '2026-10-19', '3', 1); -- --------------------------------------------------------------------------- -- 业务表(V5 迁移;业务编号自动生成,五项汇总冗余列由服务层事务内维护) @@ -874,4 +882,4 @@ CREATE TABLE alembic_version ( CONSTRAINT alembic_version_pkc PRIMARY KEY (version_num) )CHARSET=utf8mb4 ENGINE=InnoDB COLLATE utf8mb4_general_ci; -INSERT INTO alembic_version (version_num) VALUES ('ba0217f5091b'); +INSERT INTO alembic_version (version_num) VALUES ('90653814a421'); diff --git a/db/migration/V10__warning_rule_params.sql b/db/migration/V10__warning_rule_params.sql new file mode 100644 index 0000000..a373de6 --- /dev/null +++ b/db/migration/V10__warning_rule_params.sql @@ -0,0 +1,16 @@ +-- Running upgrade ba0217f5091b -> 90653814a421 + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (21, 'warning.enabled', '风险预警总开关', '预警规则', 'BOOL', NULL, 'NONE', NULL, '1', NULL, NULL, '工作台风险预警区块总开关:0=关闭(不扫描不展示) 1=开启(默认)', 21, 1); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (22, 'warning.receipt.overdue.days', '回款逾期天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '90', '1', NULL, '发货后超过该天数仍未回款的发货批次触发预警(澄清 #10 建议默认 90 天)', 22, 1); + +INSERT INTO param_definitions (id, param_code, param_name, param_group, value_type, unit, dimension_type, options_text, default_value, value_min, value_max, description, sort_order, status) VALUES (23, 'warning.approval.timeout.days', '审批超时天数阈值', '预警规则', 'INT', '天', 'NONE', NULL, '3', '1', NULL, '待审批单提交后超过该天数未处理触发预警(澄清 #10 建议默认 3 天)', 23, 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (26, 21, '', '2026-10-19', '1', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (27, 22, '', '2026-10-19', '90', 1); + +INSERT INTO param_values (id, param_id, dimension_value, effective_date, value_text, batch_id) VALUES (28, 23, '', '2026-10-19', '3', 1); + +UPDATE alembic_version SET version_num='90653814a421' WHERE alembic_version.version_num = 'ba0217f5091b'; +