feat/m3-increments #1
@ -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)
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 或容器状态)"
|
||||
|
||||
@ -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="✗"
|
||||
|
||||
Loading…
Reference in New Issue
Block a user