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