feat/m3-increments #1

Merged
liuwenxin merged 100 commits from feat/m3-increments into main 2026-10-19 04:59:57 -04:00
8 changed files with 116 additions and 22 deletions
Showing only changes of commit 45c268b146 - Show all commits

View File

@ -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="扣补说明")

View File

@ -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)

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:
"""按经理生成团队聚合行(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,

View File

@ -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)

View File

@ -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,

View File

@ -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:

View File

@ -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 或容器状态)"

View File

@ -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="✗"