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:
1 parent
828b57350a
commit
45c268b146
8 files changed
+116
-22
No files matched your search
@@ -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="扣补说明")
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in new issue
Block a user