From 45c268b1469eb8e128a9a7b87623e196125d0cd4 Mon Sep 17 00:00:00 2001 From: liuwenxin <1009266660@qq.com> Date: Mon, 19 Oct 2026 12:10:29 +0800 Subject: [PATCH] =?UTF-8?q?fix(m3):=20code-review=20P2=E2=80=94=E2=80=94?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E6=A0=A1=E9=AA=8C=E3=80=81=E5=8F=82=E6=95=B0?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E4=B8=8E=E8=BF=90=E7=BB=B4=E8=84=9A=E6=9C=AC?= =?UTF-8?q?=E5=AE=B9=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调整单金额恒正:schema 正则拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出)+ 服务层 Decimal>0 语义校验——负数会反转补发/ 扣减语义,'abc' 原样入库会以 500 暴露 - 分界天数参数解析:int(parse_fraction(...)) 替代 int(参数原文)—— '360.0' 是 INT 参数的合法入库值(Fraction 分母为 1),直接 int(str) 抛 ValueError 会让整月核算 500 - logo 旧文件清理容错:OSError 只记 warning——裸抛会把成功的 logo 更新 记成失败审计并 500(库中 logo_path 却已是新值,审计与事实相反) - health_check.sh:Redis 探活改容器内环境变量展开(密码不进宿主进程 命令行,模式同 backup_full.sh);compose redis 补 REDIS_PASSWORD - restore_drill.sh:行数核对扩到全部 32 张业务表(原 16 张漏 V7 四表、 二次分配等核心表,备份漏表演练全绿);查询失败回显 '?' 一律判 ✗ (原 '?'=='?' 判一致,表缺失时误报「备份可恢复」) - 并入 88 会话的 #12 实现:manager.personal.included 开关消费 (_build_manager_aggregates 参数化,测试见 828b573) - 测试:调整单金额恒正 5 稿、INT 参数小数原文核算 200 回归;单测 469 Co-Authored-By: Claude Code --- backend/app/schemas/settlement.py | 7 ++- backend/app/services/calc_service.py | 4 +- backend/app/services/settlement_service.py | 62 ++++++++++++++----- .../app/services/system_setting_service.py | 7 ++- backend/tests/integration/test_calc.py | 32 ++++++++++ docker/docker-compose.dev.yml | 2 + scripts/health_check.sh | 5 +- scripts/restore_drill.sh | 19 +++++- 8 files changed, 116 insertions(+), 22 deletions(-) diff --git a/backend/app/schemas/settlement.py b/backend/app/schemas/settlement.py index ea41c73..ef7afaf 100644 --- a/backend/app/schemas/settlement.py +++ b/backend/app/schemas/settlement.py @@ -86,5 +86,10 @@ class AdjustmentCreateIn(BaseModel): settlement_month: str = Field(description="调整月份 YYYY-MM") personnel_id: int = Field(ge=1, description="调整人员ID") direction: int = Field(ge=1, le=2, description="1补发 2扣减") - amount: str = Field(description="金额(元,两位小数)") + # 正则先拦格式(非负两位小数、≤12 位整数防 NUMERIC(14,2) 溢出); + # 「> 0」由服务层判定(schema 只管形状,语义校验留在 service) + amount: str = Field( + pattern=r"^\d{1,12}(\.\d{1,2})?$", + description="金额(元,两位小数,恒正)", + ) reason: str = Field(min_length=1, max_length=255, description="扣补说明") diff --git a/backend/app/services/calc_service.py b/backend/app/services/calc_service.py index 375fa39..13424f7 100644 --- a/backend/app/services/calc_service.py +++ b/backend/app/services/calc_service.py @@ -345,7 +345,9 @@ def _personal_rate( start = parse_fraction(snapshot.get("salesperson.rate.start")) slope = parse_fraction(snapshot.get("salesperson.rate.slope")) floor = parse_fraction(snapshot.get("salesperson.rate.floor")) - boundary = int(snapshot.get("salesperson.days.boundary")) + # int(参数原文) 对合法入库的 '360.0' 会抛 ValueError 使整月核算 500; + # 经 Fraction 转换再取整(INT 型参数入库时已校验分母为 1,取整无损) + boundary = int(parse_fraction(snapshot.get("salesperson.days.boundary"))) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary) note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)" return _RateDecision(rate, note, None, False) diff --git a/backend/app/services/settlement_service.py b/backend/app/services/settlement_service.py index d98b565..53c915f 100644 --- a/backend/app/services/settlement_service.py +++ b/backend/app/services/settlement_service.py @@ -324,17 +324,42 @@ def _confirm_scope(session: Session, month: str) -> list: def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list: """按经理生成团队聚合行(calc_type=2)。 - #12 策略点:聚合基数**不含**经理个人户业绩(customer_type= - manager_personal 的行按经理个人户单笔处理,已落台账但此处不入基数)。 + #12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数 + ``manager.personal.included`` 决定(0=不计入默认,1=计入)——该部分 + 按经理个人户规则单笔落台账(rate=0/绩效 0),是否并入聚合基数由此 + 开关控制。 经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal 的人员(本人带领的业务员经 manager_id 关联)。 """ from app.models import PerformanceLedger + from app.repositories import business_role_repository + from app.services.param_service import parse_fraction, resolve_snapshot + + # 取参锚定所属月月末(不是生成日):补生成历史月份时结果不随操作日期漂移 + param_date = _month_end(month) + snapshot = resolve_snapshot(session, param_date) + # #12:经理个人户基数开关(与分成比例同日取参,答复后调整可追溯) + include_manager_personal = snapshot.get("manager.personal.included") == "1" managers: dict[int, dict] = {} for row in ledger_rows: + if not include_manager_personal and row.customer_type == "manager_personal": + continue # 经理个人户业绩不入聚合基数(#12 参数化,默认不计入) if row.customer_type == "manager_personal": - continue # 经理个人户业绩不入聚合基数(#12 暂口径) + # 开关计入:个人户行归属经理本人(行 personnel_id 即经理, + # 不走 manager_id 上溯——经理没有上级,按上溯会被跳过漏计) + bucket = managers.setdefault( + row.personnel_id, + { + "performance": Decimal("0"), + "business_id": row.business_id, + "business_no": row.business_no, + "names": set(), + }, + ) + bucket["performance"] += row.performance_amount or Decimal("0") + bucket["names"].add(row.personnel_name) + continue # 找到该行业务归属人员的经理(manager_id) person = session.get(Personnel, row.personnel_id) manager_id = person.manager_id if person else None @@ -352,19 +377,13 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) - bucket["performance"] += row.performance_amount or Decimal("0") bucket["names"].add(row.personnel_name) - from app.repositories import business_role_repository - from app.services.param_service import parse_fraction, resolve_snapshot - - # 取参锚定所属月月末(不是生成日):补生成历史月份时结果不随操作日期漂移 - param_date = _month_end(month) - aggregates: list = [] - snapshot = resolve_snapshot(session, param_date) # 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5, # manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍) team_text = snapshot.get("manager.share.team") company_text = snapshot.get("manager.share.company") team_pct = parse_fraction(team_text) company_pct = parse_fraction(company_text) + aggregates: list = [] for manager_id, bucket in managers.items(): manager = session.get(Personnel, manager_id) if manager is None: @@ -376,12 +395,12 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) - ) if role is None or role.role_code != "sales_manager": continue # 非营销经理的上级不参与聚合 - # 全公司基数:全部非经理个人户的有效行 + # 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤) company_base = sum( ( r.performance_amount or Decimal("0") for r in ledger_rows - if r.customer_type != "manager_personal" + if include_manager_personal or r.customer_type != "manager_personal" ), Decimal("0"), ) @@ -421,7 +440,11 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) - "company_base": str(company_base), "members": member_names, "params_as_of": str(param_date), - "note": "#12 暂口径:基数不含经理个人户业绩;9.8 方案公式", + "manager_personal_included": 1 if include_manager_personal else 0, + "note": ( + f"#12 参数化:基数{'含' if include_manager_personal else '不含'}" + "经理个人户业绩;9.8 方案公式" + ), }, ), ) @@ -474,6 +497,8 @@ def create_adjustment( meta: RequestMeta | None = None, ): """新建调整单:开关分流(关=提交即生效留痕;开=待审批)。""" + from decimal import InvalidOperation + from app.core.constants import NO_PREFIX_ADJUSTMENT from app.models import AdjustmentOrder from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT @@ -483,6 +508,15 @@ def create_adjustment( ) from app.services.no_generator import next_no + # 金额恒正(方向表达补发/扣减):列注释即此约定,负数会让方向语义反转、 + # 审批/报表按 direction 汇总出错;格式已由 schema 正则拦截,此处兜底 + 语义判定 + try: + amount = Decimal(payload.amount) + except InvalidOperation as exc: + raise BadRequestError("调整金额格式非法(非负两位小数)") from exc + if amount <= 0: + raise BadRequestError("调整金额须大于 0(方向表达补发/扣减)") + person = session.get(Personnel, payload.personnel_id) if person is None: raise BadRequestError("调整人员不存在") @@ -509,7 +543,7 @@ def create_adjustment( personnel_id=payload.personnel_id, personnel_name=person.personnel_name, direction=payload.direction, - amount=payload.amount, + amount=amount, reason=payload.reason.strip(), submitter_id=operator.id, submitter_name=operator.username, diff --git a/backend/app/services/system_setting_service.py b/backend/app/services/system_setting_service.py index bfeac7c..bb0af93 100644 --- a/backend/app/services/system_setting_service.py +++ b/backend/app/services/system_setting_service.py @@ -137,7 +137,12 @@ def save_logo( old_ext = Path(old_path).suffix.lstrip(".") if old_path else None if old_ext and old_ext != ext: - _cleanup_old_logo(old_ext) + try: + _cleanup_old_logo(old_ext) + except OSError: + # 清理失败不影响本次更新(库与新文件均已生效);若异常外抛, + # 成功的 logo 更新会被记成失败审计并 500 —— 审计与事实相反 + logger.warning("旧 logo 清理失败(不影响本次更新)| ext=%s", old_ext, exc_info=True) logger.info("更新公司 logo | size=%s ext=%s", len(content), ext) return to_out(setting) diff --git a/backend/tests/integration/test_calc.py b/backend/tests/integration/test_calc.py index 0463776..90372ac 100644 --- a/backend/tests/integration/test_calc.py +++ b/backend/tests/integration/test_calc.py @@ -357,6 +357,38 @@ class TestGuards: resp = _calc(client, ops["headers"], receipt["id"]) assert resp.status_code == 200, resp.text + def test_分界天数参数小数原文_核算不500(self, client: TestClient, admin: dict) -> None: + """守 int(参数原文) 缺陷:INT 参数入库允许 '360.0'(Fraction 分母为 1), + 直接 int(str) 抛 ValueError 会让整月核算 500。""" + resp = client.post( + "/api/v1/system/params/changes", + json={ + "change_type": 1, + "remark": "用例:INT 参数小数原文", + "items": [ + { + "param_code": "salesperson.days.boundary", + "dimension_value": "salesperson", + "value": "360.0", + } + ], + }, + headers=admin["headers"], + ) + assert resp.status_code == 200, resp.text + _biz, receipt = _create_full_chain( + client, + admin["headers"], + items=_GOLD_ITEMS, + ship_date="2026-10-01", + receipt_date="2026-10-20", + amount="20000.00", + ) + resp = _calc(client, admin["headers"], receipt["id"]) + assert resp.status_code == 200, resp.text + # 分界取整无损:20 天 < 360 天 → 同一递减公式 + assert resp.json()["data"]["ledger"]["rate_text"] == "11/1125" + def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None: biz, receipt = _create_full_chain( client, diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 7cf69c3..3c242ec 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -38,6 +38,8 @@ services: restart: unless-stopped environment: TZ: Asia/Shanghai + # 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql) + REDIS_PASSWORD: ${REDIS_PASSWORD} # appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢 command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} volumes: diff --git a/scripts/health_check.sh b/scripts/health_check.sh index 24bfc7f..029a305 100644 --- a/scripts/health_check.sh +++ b/scripts/health_check.sh @@ -67,9 +67,10 @@ else fi fi -# 4. Redis 存活(密码经宿主 docker/.env,不经命令行明文参数) +# 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行; +# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD) if [[ -n "${REDIS_PASSWORD:-}" ]]; then - if docker exec ys-redis-dev redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping 2>/dev/null | grep -q PONG; then + if docker exec ys-redis-dev sh -c 'redis-cli --no-auth-warning -a "$REDIS_PASSWORD" ping' 2>/dev/null | grep -q PONG; then pass "Redis 响应 PONG" else warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)" diff --git a/scripts/restore_drill.sh b/scripts/restore_drill.sh index 98dc0dc..51da158 100644 --- a/scripts/restore_drill.sh +++ b/scripts/restore_drill.sh @@ -61,8 +61,16 @@ docker exec "$CONTAINER" sh -c \ gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \ "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\"" -# 3. 行数核对:关键业务表 -TABLES=(users roles user_roles param_definitions param_values businesses business_items personnel products customers shipments shipment_items receipts approval_orders approval_steps system_settings) +# 3. 行数核对:**全部业务表**(新增表必须同步补入本清单——备份漏表时 +# 演练要能发现;alembic_version 不列,行数恒 1 无核对价值) +TABLES=( + adjustment_orders approval_orders approval_steps business_items business_roles + business_secondary_allocations business_status_logs businesses customer_rating_grades + customers login_logs monthly_settlements operation_logs ownership_change_logs + param_change_batches param_definitions param_values performance_ledgers permissions + personnel personnel_plans personnel_regions products receipts role_permissions roles + settlement_items shipment_items shipments system_settings user_roles users +) echo "" echo "======== 行数对比(源库 vs 演练库)========" @@ -73,7 +81,12 @@ for T in "${TABLES[@]}"; do "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $MYSQL_DATABASE.$T;\" 2>/dev/null" || echo "?") DST=$(docker exec "$CONTAINER" sh -c \ "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" -N -B -e \"SELECT COUNT(*) FROM $DRILL_DB.$T;\" 2>/dev/null" || echo "?") - if [[ "$SRC" == "$DST" ]]; then + if [[ "$SRC" == "?" || "$DST" == "?" ]]; then + # 查询失败(表缺失/权限/客户端故障)不可与「数据一致」混淆:? + # 与 ? 相等会误报可恢复——备份真的漏表时演练必须红 + MARK="✗" + ALL_OK=0 + elif [[ "$SRC" == "$DST" ]]; then MARK="✓" else MARK="✗"