PayrollManagementSystem/backend/app/services/coefficient_service.py

331 lines
13 KiB
Python
Raw Normal View History

"""系数表是参数版本的编辑视图,不保留第二套工资公式或独立系数数据。"""
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()