fix(m3): code-review P2——金额校验、参数解析与运维脚本容错

- 调整单金额恒正: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 <noreply@anthropic.com>
This commit is contained in:
liuwenxin 2026-10-19 12:10:29 +08:00
parent 828b57350a
commit 45c268b146
8 changed files with 116 additions and 22 deletions

View File

@ -86,5 +86,10 @@ class AdjustmentCreateIn(BaseModel):
settlement_month: str = Field(description="调整月份 YYYY-MM") settlement_month: str = Field(description="调整月份 YYYY-MM")
personnel_id: int = Field(ge=1, description="调整人员ID") personnel_id: int = Field(ge=1, description="调整人员ID")
direction: int = Field(ge=1, le=2, description="1补发 2扣减") 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="扣补说明") reason: str = Field(min_length=1, max_length=255, description="扣补说明")

View File

@ -345,7 +345,9 @@ def _personal_rate(
start = parse_fraction(snapshot.get("salesperson.rate.start")) start = parse_fraction(snapshot.get("salesperson.rate.start"))
slope = parse_fraction(snapshot.get("salesperson.rate.slope")) slope = parse_fraction(snapshot.get("salesperson.rate.slope"))
floor = parse_fraction(snapshot.get("salesperson.rate.floor")) 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) rate = salesperson_rate(days, start=start, slope=slope, floor=floor, boundary_days=boundary)
note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)" note = f"业务员递减:{start}‰ 起,{days} 天(分界 {boundary} 天,超界取 {floor}‰)"
return _RateDecision(rate, note, None, False) return _RateDecision(rate, note, None, False)

View File

@ -324,17 +324,42 @@ def _confirm_scope(session: Session, month: str) -> list:
def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list: def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -> list:
"""按经理生成团队聚合行(calc_type=2)。 """按经理生成团队聚合行(calc_type=2)。
#12 策略点:聚合基数**不含**经理个人户业绩(customer_type= #12 答复(2026-10-19)参数化:经理个人户业绩是否计入聚合基数由参数
manager_personal 的行按经理个人户单笔处理,已落台账但此处不入基数)。 ``manager.personal.included`` 决定(0=不计入默认,1=计入)——该部分
按经理个人户规则单笔落台账(rate=0/绩效 0),是否并入聚合基数由此
开关控制。
经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal 经理 = 本月台账行里 role_code=sales_manager 且 customer_type=personal
的人员(本人带领的业务员经 manager_id 关联)。 的人员(本人带领的业务员经 manager_id 关联)。
""" """
from app.models import PerformanceLedger 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] = {} managers: dict[int, dict] = {}
for row in ledger_rows: for row in ledger_rows:
if not include_manager_personal and row.customer_type == "manager_personal":
continue # 经理个人户业绩不入聚合基数(#12 参数化,默认不计入)
if row.customer_type == "manager_personal": 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) # 找到该行业务归属人员的经理(manager_id)
person = session.get(Personnel, row.personnel_id) person = session.get(Personnel, row.personnel_id)
manager_id = person.manager_id if person else None 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["performance"] += row.performance_amount or Decimal("0")
bucket["names"].add(row.personnel_name) 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, # 参数按百分数存储(12.5 表示 12.5%):parse_fraction 得 12.5,
# manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍) # manager_team_share 内部再 ÷100 —— 调用方不得再乘 100(否则放大 100 倍)
team_text = snapshot.get("manager.share.team") team_text = snapshot.get("manager.share.team")
company_text = snapshot.get("manager.share.company") company_text = snapshot.get("manager.share.company")
team_pct = parse_fraction(team_text) team_pct = parse_fraction(team_text)
company_pct = parse_fraction(company_text) company_pct = parse_fraction(company_text)
aggregates: list = []
for manager_id, bucket in managers.items(): for manager_id, bucket in managers.items():
manager = session.get(Personnel, manager_id) manager = session.get(Personnel, manager_id)
if manager is None: 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": if role is None or role.role_code != "sales_manager":
continue # 非营销经理的上级不参与聚合 continue # 非营销经理的上级不参与聚合
# 全公司基数:全部非经理个人户的有效行 # 全公司基数:默认排除经理个人户行(#12 开关计入时不过滤)
company_base = sum( company_base = sum(
( (
r.performance_amount or Decimal("0") r.performance_amount or Decimal("0")
for r in ledger_rows for r in ledger_rows
if r.customer_type != "manager_personal" if include_manager_personal or r.customer_type != "manager_personal"
), ),
Decimal("0"), Decimal("0"),
) )
@ -421,7 +440,11 @@ def _build_manager_aggregates(session: Session, ledger_rows: list, month: str) -
"company_base": str(company_base), "company_base": str(company_base),
"members": member_names, "members": member_names,
"params_as_of": str(param_date), "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, meta: RequestMeta | None = None,
): ):
"""新建调整单:开关分流(关=提交即生效留痕;开=待审批)。""" """新建调整单:开关分流(关=提交即生效留痕;开=待审批)。"""
from decimal import InvalidOperation
from app.core.constants import NO_PREFIX_ADJUSTMENT from app.core.constants import NO_PREFIX_ADJUSTMENT
from app.models import AdjustmentOrder from app.models import AdjustmentOrder
from app.models.approval import APPROVAL_STATUS_APPROVED, APPROVAL_TYPE_ADJUSTMENT 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 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) person = session.get(Personnel, payload.personnel_id)
if person is None: if person is None:
raise BadRequestError("调整人员不存在") raise BadRequestError("调整人员不存在")
@ -509,7 +543,7 @@ def create_adjustment(
personnel_id=payload.personnel_id, personnel_id=payload.personnel_id,
personnel_name=person.personnel_name, personnel_name=person.personnel_name,
direction=payload.direction, direction=payload.direction,
amount=payload.amount, amount=amount,
reason=payload.reason.strip(), reason=payload.reason.strip(),
submitter_id=operator.id, submitter_id=operator.id,
submitter_name=operator.username, submitter_name=operator.username,

View File

@ -137,7 +137,12 @@ def save_logo(
old_ext = Path(old_path).suffix.lstrip(".") if old_path else None old_ext = Path(old_path).suffix.lstrip(".") if old_path else None
if old_ext and old_ext != ext: 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) logger.info("更新公司 logo | size=%s ext=%s", len(content), ext)
return to_out(setting) return to_out(setting)

View File

@ -357,6 +357,38 @@ class TestGuards:
resp = _calc(client, ops["headers"], receipt["id"]) resp = _calc(client, ops["headers"], receipt["id"])
assert resp.status_code == 200, resp.text 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: def test_台账列表与筛选(self, client: TestClient, admin: dict) -> None:
biz, receipt = _create_full_chain( biz, receipt = _create_full_chain(
client, client,

View File

@ -38,6 +38,8 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
TZ: Asia/Shanghai TZ: Asia/Shanghai
# 健康检查脚本在容器内展开(密码不进宿主进程命令行,模式同 mysql)
REDIS_PASSWORD: ${REDIS_PASSWORD}
# appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢 # appendonly 开启 AOF,容器重启后验证码黑名单等数据不丢
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes: volumes:

View File

@ -67,9 +67,10 @@ else
fi fi
fi fi
# 4. Redis 存活(密码经宿主 docker/.env,不经命令行明文参数) # 4. Redis 存活(密码经容器内环境变量展开,不出现在宿主进程命令行;
# 写法同 backup_full.sh 的 MYSQL_ROOT_PASSWORD)
if [[ -n "${REDIS_PASSWORD:-}" ]]; then 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" pass "Redis 响应 PONG"
else else
warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)" warn "Redis ping 无响应(检查 REDIS_PASSWORD 或容器状态)"

View File

@ -61,8 +61,16 @@ docker exec "$CONTAINER" sh -c \
gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \ gunzip -c "$LATEST" | docker exec -i "$CONTAINER" sh -c \
"mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\"" "mysql -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$DRILL_DB\""
# 3. 行数核对:关键业务表 # 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) # 演练要能发现;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 ""
echo "======== 行数对比(源库 vs 演练库)========" 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 "?") "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 \ 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 "?") "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="✓" MARK="✓"
else else
MARK="✗" MARK="✗"