按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
331 lines
13 KiB
Python
331 lines
13 KiB
Python
"""系数表是参数版本的编辑视图,不保留第二套工资公式或独立系数数据。"""
|
||
|
||
from datetime import date
|
||
from decimal import ROUND_HALF_UP, Decimal
|
||
from fractions import Fraction
|
||
from io import BytesIO
|
||
from zipfile import ZipFile
|
||
|
||
from openpyxl import Workbook, load_workbook
|
||
from pydantic import ValidationError
|
||
from sqlalchemy import select
|
||
|
||
from app.core.exceptions import BadRequestError
|
||
from app.schemas.coefficient import CoefficientRowIn
|
||
from app.schemas.param import ParamChangeIn, ParamChangeItemIn
|
||
from app.services import param_service
|
||
from app.services.param_registry import get_spec, validate_and_normalize
|
||
from app.services.rate_rules import decide, personal_values
|
||
from app.services.tplus.workbench import digest
|
||
|
||
CATEGORIES = ("municipal_water", "rail_transit")
|
||
HEADERS = ("product_category", "customer_type", "stage", "value", "slope", "boundary")
|
||
|
||
|
||
def _coefficient(rate):
|
||
return str(1 / rate) if rate else None
|
||
|
||
|
||
def _average_text(value):
|
||
if value is None:
|
||
return None
|
||
return format(
|
||
param_service.to_decimal(value).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP), "f"
|
||
)
|
||
|
||
|
||
def _revision(session, lock=False):
|
||
if session is None:
|
||
return []
|
||
from app.models import ParamChangeBatch
|
||
|
||
query = select(
|
||
ParamChangeBatch.id, ParamChangeBatch.effective_date, ParamChangeBatch.version_no
|
||
).order_by(ParamChangeBatch.id)
|
||
if lock:
|
||
query = query.with_for_update()
|
||
return [tuple(row) for row in session.execute(query)]
|
||
|
||
|
||
def catalog(session, as_of=None, revision=None):
|
||
day = as_of or date.today()
|
||
snap = param_service.resolve_snapshot(session, day)
|
||
rows = []
|
||
for category in CATEGORIES:
|
||
values = personal_values(snap, category)
|
||
params = {**values, "enterprise": snap.get("enterprise.rate", product_category=category)}
|
||
rates = [
|
||
decide(
|
||
params, category=category, customer_type="personal", role_code="salesperson", days=d
|
||
)[0]
|
||
for d in range(1, 361)
|
||
]
|
||
average = sum((1 / r for r in rates if r), Fraction(0)) / 360 if all(rates) else None
|
||
for stage, field, days in (
|
||
("first", "first", 1),
|
||
("linear", "start", 2),
|
||
("after", "floor", int(Fraction(values["boundary"])) + 1),
|
||
):
|
||
rate = decide(
|
||
params,
|
||
category=category,
|
||
customer_type="personal",
|
||
role_code="salesperson",
|
||
days=days,
|
||
)[0]
|
||
rows.append(
|
||
{
|
||
"product_category": category,
|
||
"customer_type": "personal",
|
||
"stage": stage,
|
||
"value": values[field],
|
||
"slope": values["slope"] if stage == "linear" else None,
|
||
"boundary": int(Fraction(values["boundary"])) if stage == "linear" else None,
|
||
"rate": str(rate),
|
||
"system_coefficient": _coefficient(rate),
|
||
"average_coefficient_360": _average_text(average),
|
||
}
|
||
)
|
||
rate = Fraction(params["enterprise"]) / 1000
|
||
rows.append(
|
||
{
|
||
"product_category": category,
|
||
"customer_type": "enterprise",
|
||
"stage": "fixed",
|
||
"value": params["enterprise"],
|
||
"slope": None,
|
||
"boundary": None,
|
||
"rate": str(rate),
|
||
"system_coefficient": _coefficient(rate),
|
||
"average_coefficient_360": _average_text(1 / rate if rate else None),
|
||
}
|
||
)
|
||
rows.append(
|
||
{
|
||
"product_category": category,
|
||
"customer_type": "manager_personal",
|
||
"stage": "excluded",
|
||
"value": "0",
|
||
"rate": "0",
|
||
"system_coefficient": None,
|
||
"average_coefficient_360": None,
|
||
"readonly": True,
|
||
}
|
||
)
|
||
return {
|
||
"as_of": str(day),
|
||
"rows": rows,
|
||
"fingerprint": digest(
|
||
{
|
||
"as_of": day,
|
||
"rows": rows,
|
||
"revision": _revision(session) if revision is None else revision,
|
||
}
|
||
),
|
||
"average_rule": "第1—360天每日系统系数的算术平均,展示保留6位小数;存在零计提日时不适用。经理个人户不单笔计提。",
|
||
}
|
||
|
||
|
||
def items_for(rows):
|
||
items = []
|
||
seen = set()
|
||
for row in rows:
|
||
key = (row.product_category, row.customer_type, row.stage)
|
||
if key in seen:
|
||
raise BadRequestError("存在重复的产品/户型/阶段")
|
||
seen.add(key)
|
||
if row.customer_type == "enterprise":
|
||
if row.stage != "fixed":
|
||
raise BadRequestError("企业户使用固定阶段")
|
||
fields = [("enterprise.rate", row.value)]
|
||
elif row.stage == "fixed":
|
||
raise BadRequestError("个人户请选择首日、线性或超期阶段")
|
||
elif row.stage == "linear":
|
||
if row.slope is None or row.boundary is None:
|
||
raise BadRequestError("线性阶段须填写日斜率和截止天数")
|
||
try:
|
||
endpoint = Fraction(row.value) - row.boundary * Fraction(row.slope)
|
||
except (ValueError, ZeroDivisionError) as exc:
|
||
raise BadRequestError("比例及日斜率须为有效数值或分数") from exc
|
||
if endpoint < 0:
|
||
raise BadRequestError("线性阶段末比例不能为负数")
|
||
fields = [
|
||
("coefficient.personal.start", row.value),
|
||
("coefficient.personal.slope", row.slope),
|
||
("coefficient.personal.boundary", str(row.boundary)),
|
||
]
|
||
else:
|
||
fields = [
|
||
(
|
||
"coefficient.personal.first"
|
||
if row.stage == "first"
|
||
else "coefficient.personal.floor",
|
||
row.value,
|
||
)
|
||
]
|
||
for code, value in fields:
|
||
normalized = validate_and_normalize(get_spec(code), value)
|
||
items.append(
|
||
ParamChangeItemIn(
|
||
param_code=code, dimension_value=row.product_category, value=normalized
|
||
)
|
||
)
|
||
return items
|
||
|
||
|
||
def save(session, payload, operator, meta=None):
|
||
from app.models import ParamDefinition
|
||
|
||
list(session.scalars(select(ParamDefinition).order_by(ParamDefinition.id).with_for_update()))
|
||
revision = _revision(session, lock=True)
|
||
if payload.fingerprint != catalog(session, payload.base_date, revision)["fingerprint"]:
|
||
raise BadRequestError("系数表已变化,请重新查询或重新导入预览")
|
||
return param_service.submit_change(
|
||
session,
|
||
ParamChangeIn(
|
||
change_type=2,
|
||
effective_date=payload.effective_date,
|
||
remark=payload.remark,
|
||
items=items_for(payload.rows),
|
||
),
|
||
operator=operator,
|
||
meta=meta,
|
||
)
|
||
|
||
|
||
def workbook(session, as_of=None):
|
||
data = catalog(session, as_of)
|
||
book = Workbook()
|
||
sheet = book.active
|
||
sheet.title = "系数参数"
|
||
sheet.append([*HEADERS, "system_coefficient", "average_coefficient_360"])
|
||
for row in data["rows"]:
|
||
if row.get("readonly"):
|
||
continue
|
||
sheet.append(
|
||
[row.get(k) for k in (*HEADERS, "system_coefficient", "average_coefficient_360")]
|
||
)
|
||
note = book.create_sheet("填写说明")
|
||
note.append(["基准日期", data["as_of"]])
|
||
for line in (
|
||
"value为‰数;线性阶段value为公式起点,slope为每日递减‰数,可填写分数。",
|
||
"产品:municipal_water市政水利/rail_transit轨道交通;户型personal个人户/enterprise企业户。",
|
||
"阶段:first首日/linear线性/after超期/fixed企业固定;经理个人户不单笔计提,不导入。",
|
||
data["average_rule"],
|
||
"系统系数及平均系数是只读派生列,导入时可留空,填写则必须与参数一致。",
|
||
):
|
||
note.append([line])
|
||
for column in sheet.columns:
|
||
sheet.column_dimensions[column[0].column_letter].width = 28
|
||
sheet.freeze_panes = "A2"
|
||
output = BytesIO()
|
||
book.save(output)
|
||
return output.getvalue()
|
||
|
||
|
||
def import_preview(session, content):
|
||
if len(content) > 2 * 1024 * 1024:
|
||
raise BadRequestError("系数文件不能超过2MB")
|
||
try:
|
||
with ZipFile(BytesIO(content)) as archive:
|
||
if sum(item.file_size for item in archive.infolist()) > 20 * 1024 * 1024:
|
||
raise BadRequestError("工作簿解压后超过20MB,请使用系数模板")
|
||
book = load_workbook(BytesIO(content), read_only=True, data_only=False)
|
||
sheet = book["系数参数"]
|
||
if sheet.max_row > 30 or sheet.max_column > 8:
|
||
raise BadRequestError("系数表最多16条数据")
|
||
values = list(sheet.iter_rows(values_only=True))
|
||
if tuple(values[0][:6]) != HEADERS:
|
||
raise BadRequestError("请使用系数表模板,保留前六列列名")
|
||
rows = []
|
||
errors = []
|
||
for number, value in enumerate(values[1:], 2):
|
||
if not any(v is not None for v in value):
|
||
continue
|
||
try:
|
||
raw = {
|
||
k: str(v) if v is not None else None
|
||
for k, v in zip(HEADERS, value[:6], strict=True)
|
||
}
|
||
raw["boundary"] = int(raw["boundary"]) if raw["boundary"] else None
|
||
row = CoefficientRowIn.model_validate(raw)
|
||
items_for([row])
|
||
rows.append(row)
|
||
except (ValidationError, ValueError, BadRequestError) as exc:
|
||
errors.append({"row": number, "message": str(exc)[:180]})
|
||
if not rows and not errors:
|
||
errors.append({"row": 2, "message": "文件没有可导入数据"})
|
||
if rows:
|
||
items_for(rows)
|
||
# 派生列只做校验,不作为第二套输入。用临时参数快照复算。
|
||
base = date.fromisoformat(str(book["填写说明"].cell(1, 2).value))
|
||
snap = param_service.resolve_snapshot(session, base)
|
||
temporary = {code: dict(dims) for code, dims in snap._values.items()}
|
||
for item in items_for(rows):
|
||
temporary.setdefault(item.param_code, {})[item.dimension_value] = item.value
|
||
candidate = param_service.ParamSnapshot(base, temporary, {})
|
||
for number, raw in enumerate(values[1:], 2):
|
||
if len(raw) < 7:
|
||
continue
|
||
matching = next(
|
||
(
|
||
r
|
||
for r in rows
|
||
if (r.product_category, r.customer_type, r.stage) == tuple(raw[:3])
|
||
),
|
||
None,
|
||
)
|
||
if matching is None:
|
||
continue
|
||
pv = personal_values(candidate, matching.product_category)
|
||
day = {"first": 1, "linear": 2, "after": int(Fraction(pv["boundary"])) + 1, "fixed": 1}[
|
||
matching.stage
|
||
]
|
||
rate = decide(
|
||
{
|
||
**pv,
|
||
"enterprise": candidate.get(
|
||
"enterprise.rate", product_category=matching.product_category
|
||
),
|
||
},
|
||
category=matching.product_category,
|
||
customer_type=matching.customer_type,
|
||
role_code="salesperson",
|
||
days=day,
|
||
)[0]
|
||
if raw[6] not in (None, "") and (rate == 0 or Fraction(str(raw[6])) != 1 / rate):
|
||
errors.append(
|
||
{"row": number, "message": "系统系数与公式参数不一致,请清空派生列或重新导出"}
|
||
)
|
||
if len(raw) > 7 and raw[7] not in (None, ""):
|
||
if matching.customer_type == "enterprise":
|
||
average = 1 / rate if rate else None
|
||
else:
|
||
daily = [
|
||
decide(
|
||
{**pv, "enterprise": "0"},
|
||
category=matching.product_category,
|
||
customer_type="personal",
|
||
role_code="salesperson",
|
||
days=d,
|
||
)[0]
|
||
for d in range(1, 361)
|
||
]
|
||
average = sum((1 / r for r in daily), Fraction(0)) / 360 if all(daily) else None
|
||
if average is None or Decimal(str(raw[7])) != Decimal(_average_text(average)):
|
||
errors.append({"row": number, "message": "360天平均系数与公式参数不一致"})
|
||
return {
|
||
"rows": [r.model_dump() for r in rows],
|
||
"errors": errors,
|
||
"can_apply": not errors,
|
||
"fingerprint": catalog(session, base)["fingerprint"],
|
||
"base_date": str(base),
|
||
}
|
||
except BadRequestError:
|
||
raise
|
||
except Exception as exc:
|
||
raise BadRequestError("无法读取系数表,请检查工作表名称和文件格式") from exc
|
||
finally:
|
||
if "book" in locals():
|
||
book.close()
|