按 2026-11-02 需求补齐计划交付后端能力: - 角色授权:权限目录化,接口统一准入校验,未登记默认拒绝; 管理员判断改为按数据库角色重新判定,防止权限码持有者绕过 - 系统监控:单进程采集资源、请求与异常指标,分钟级聚合, 阈值配置化;敏感配置增加 repr 保护,Redis 密码 URL 转义 - 试用期计提:按回款日判定并写入快照,年度结清与调整单联动; 人员模型补试用期、离职日期与工资版本历史字段 - 计提模板:公式骨架受控,页面维护名称与参数绑定 - T+ 采集:自动化配置表与进度查询 内置角色禁停用保护对齐模块注释;核算规则版本提为常量并 补充批量核算失败堆栈日志。 影响范围:后端全部模块、数据库迁移 V22-V28 验证:uv run ruff check 通过;922 个单元测试全部通过; 集成测试因本机 MySQL 数据卷被其他容器锁定暂未运行
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""试用期计提、缓发和年度结清;支付受原业务起付及平方权重约束。"""
|
||
|
||
from datetime import date
|
||
from decimal import Decimal
|
||
from fractions import Fraction
|
||
from io import BytesIO
|
||
|
||
from openpyxl import Workbook
|
||
from sqlalchemy import select
|
||
|
||
from app.models import AdjustmentOrder, PerformanceLedger, ProbationDeferral, ProbationPayment
|
||
from app.services import param_service
|
||
|
||
ZERO = Decimal("0")
|
||
|
||
|
||
def payment_state(session, payment, lock=False):
|
||
if payment.adjustment_order_id:
|
||
order = session.get(AdjustmentOrder, payment.adjustment_order_id, with_for_update=lock, populate_existing=lock)
|
||
return "ignored" if order is None or order.status not in (1, 2) else "reserved" if order.status == 1 else "paid"
|
||
if payment.settlement_id:
|
||
from app.models import MonthlySettlement
|
||
|
||
settlement = session.get(MonthlySettlement, payment.settlement_id, with_for_update=lock, populate_existing=lock)
|
||
return "ignored" if settlement is None or settlement.status == 4 else "reserved" if settlement.status != 3 else "paid"
|
||
return "ignored"
|
||
|
||
|
||
def on_date(person, day):
|
||
if hasattr(person, "salary_history_json"):
|
||
from app.services.personnel_salary import resolve
|
||
|
||
person = resolve(person, day)
|
||
start = person.probation_start_date
|
||
end = person.regularization_date
|
||
if start or end:
|
||
return (start is None or day >= start) and (end is None or day < end)
|
||
return person.is_probation == 1
|
||
|
||
|
||
def record(session, ledger):
|
||
if ledger.is_probation != 1:
|
||
return
|
||
if session.scalar(select(ProbationDeferral.id).where(ProbationDeferral.ledger_id == ledger.id)):
|
||
return
|
||
event = (ledger.snapshot_json or {}).get("event", {})
|
||
session.add(
|
||
ProbationDeferral(
|
||
ledger_id=ledger.id,
|
||
personnel_id=ledger.personnel_id,
|
||
year=str(event.get("probation_year") or ledger.receipt_date.year),
|
||
month=ledger.settlement_month,
|
||
accrued=ledger.performance_amount or ZERO,
|
||
eligible=ledger.payable_amount or ZERO,
|
||
)
|
||
)
|
||
|
||
|
||
def preview(session, month, personnel_id=None, *, lock=False):
|
||
"""查询不写表;旧试用期台账同样进入缓发查询,历史标记不重分类。"""
|
||
from app.core.month import validate_month
|
||
|
||
validate_month(month)
|
||
statement = (
|
||
select(PerformanceLedger)
|
||
.where(
|
||
PerformanceLedger.is_active == 1,
|
||
PerformanceLedger.is_probation == 1,
|
||
PerformanceLedger.settlement_month <= month,
|
||
)
|
||
.order_by(PerformanceLedger.personnel_id, PerformanceLedger.id)
|
||
)
|
||
if personnel_id:
|
||
statement = statement.where(PerformanceLedger.personnel_id == personnel_id)
|
||
if lock:
|
||
statement = statement.with_for_update().execution_options(populate_existing=True)
|
||
grouped = {}
|
||
for ledger in session.scalars(statement):
|
||
event = (ledger.snapshot_json or {}).get("event", {})
|
||
year = str(event.get("probation_year") or ledger.receipt_date.year)
|
||
key = ledger.personnel_id, year
|
||
row = grouped.setdefault(
|
||
key,
|
||
{
|
||
"personnel_id": ledger.personnel_id,
|
||
"personnel_name": ledger.personnel_name,
|
||
"year": year,
|
||
"accrued": ZERO,
|
||
"eligible": ZERO,
|
||
"paid": ZERO,
|
||
"reserved": ZERO,
|
||
"details": [],
|
||
},
|
||
)
|
||
row["accrued"] += ledger.performance_amount or ZERO
|
||
row["eligible"] += ledger.payable_amount or ZERO
|
||
row["details"].append(
|
||
{
|
||
"ledger_id": ledger.id,
|
||
"business_no": ledger.business_no,
|
||
"month": ledger.settlement_month,
|
||
"receipt_date": str(ledger.receipt_date),
|
||
"accrued": str(ledger.performance_amount or ZERO),
|
||
"eligible": str(ledger.payable_amount or ZERO),
|
||
}
|
||
)
|
||
payments = select(ProbationPayment).where(ProbationPayment.month <= month)
|
||
if lock:
|
||
payments = payments.with_for_update().execution_options(populate_existing=True)
|
||
for payment in session.scalars(payments):
|
||
state = payment_state(session, payment, lock)
|
||
if state == "ignored":
|
||
continue
|
||
key = payment.personnel_id, payment.year
|
||
if key in grouped:
|
||
grouped[key][state] += payment.amount
|
||
result = []
|
||
for (_, year), row in sorted(grouped.items()):
|
||
snap = param_service.resolve_snapshot(session, date(int(year), 12, 31))
|
||
threshold = param_service.to_decimal(Fraction(snap.get("probation.annual.threshold")))
|
||
entitlement = max(row["accrued"] - threshold, ZERO)
|
||
available = min(entitlement, max(row["eligible"], ZERO))
|
||
enabled = month >= year + "-12"
|
||
delta = available - row["paid"] - row["reserved"] if enabled else ZERO
|
||
result.append(
|
||
{
|
||
**{
|
||
k: v
|
||
for k, v in row.items()
|
||
if k not in ("accrued", "eligible", "paid", "reserved")
|
||
},
|
||
"accrued": str(row["accrued"]),
|
||
"eligible": str(row["eligible"]),
|
||
"paid": str(row["paid"]),
|
||
"threshold": str(threshold),
|
||
"entitlement": str(entitlement),
|
||
"available": str(available),
|
||
"reserved": str(row["reserved"]),
|
||
"remaining": str(max(entitlement - row["paid"], ZERO)),
|
||
"delta": str(delta),
|
||
"payment_month": month,
|
||
"can_settle": enabled,
|
||
}
|
||
)
|
||
from app.services.tplus.workbench import digest
|
||
|
||
result[-1]["fingerprint"] = digest(result[-1])
|
||
return {
|
||
"month": month,
|
||
"rows": result,
|
||
"rule": "仅累计试用期绩效;超年度门槛部分仍受业务起付和R×R限制;12月及以后月份结清差额。",
|
||
}
|
||
|
||
|
||
def persist_payments(session, settlement, items):
|
||
for item in items:
|
||
for row in (item.snapshot_json or {}).get("probation_settlements", []):
|
||
amount = Decimal(row["delta"])
|
||
if amount:
|
||
session.add(
|
||
ProbationPayment(
|
||
settlement_id=settlement.id,
|
||
personnel_id=item.personnel_id,
|
||
year=row["year"],
|
||
month=settlement.settlement_month,
|
||
amount=amount,
|
||
snapshot_json=row,
|
||
)
|
||
)
|
||
|
||
|
||
def export(session, month):
|
||
data = preview(session, month)
|
||
book = Workbook()
|
||
sheet = book.active
|
||
sheet.title = "缓发及年度结清"
|
||
sheet.append(
|
||
[
|
||
"人员",
|
||
"原年度",
|
||
"试用期计提",
|
||
"符合支付条件",
|
||
"年度门槛",
|
||
"超额权益",
|
||
"累计可补发",
|
||
"历史已补发",
|
||
"待审核占用",
|
||
"本月差额",
|
||
"剩余权益",
|
||
]
|
||
)
|
||
keys = (
|
||
"personnel_name",
|
||
"year",
|
||
"accrued",
|
||
"eligible",
|
||
"threshold",
|
||
"entitlement",
|
||
"available",
|
||
"paid",
|
||
"reserved",
|
||
"delta",
|
||
"remaining",
|
||
)
|
||
detail = book.create_sheet("原台账明细")
|
||
detail.append(["人员", "年度", "台账ID", "业务编号", "月份", "到账日", "计提额", "应付变化"])
|
||
for row in data["rows"]:
|
||
values = [row[k] for k in keys]
|
||
sheet.append(values)
|
||
for cell in sheet[sheet.max_row]:
|
||
if cell.data_type == "f":
|
||
cell.data_type = "s"
|
||
for entry in row["details"]:
|
||
detail.append(
|
||
[
|
||
row["personnel_name"],
|
||
row["year"],
|
||
*[
|
||
entry[k]
|
||
for k in (
|
||
"ledger_id",
|
||
"business_no",
|
||
"month",
|
||
"receipt_date",
|
||
"accrued",
|
||
"eligible",
|
||
)
|
||
],
|
||
]
|
||
)
|
||
for cell in detail[detail.max_row]:
|
||
if cell.data_type == "f":
|
||
cell.data_type = "s"
|
||
for page in (sheet, detail):
|
||
page.freeze_panes = "A2"
|
||
for col in page.columns:
|
||
page.column_dimensions[col[0].column_letter].width = 22
|
||
output = BytesIO()
|
||
book.save(output)
|
||
return output.getvalue()
|
||
|
||
|
||
def adjustment(session, payload, actor, meta=None):
|
||
"""月结已生成后只创建当月差额调整;年度余额与调整单原子写入。"""
|
||
from app.core.config import get_settings
|
||
from app.core.exceptions import BadRequestError
|
||
from app.models import MonthlySettlement, Personnel
|
||
from app.services import approval_service, audit_service, payroll_months
|
||
from app.services.audit_service import AuditRecord
|
||
from app.services.no_generator import next_no
|
||
from app.services.settlement_service import _wire_adjustment_approval
|
||
|
||
month = payroll_months.current_month()
|
||
from app.services.payroll_access import ensure_payroll_writer
|
||
|
||
ensure_payroll_writer(actor)
|
||
payroll_months.lock_month(session, month, actor)
|
||
if (
|
||
session.scalar(
|
||
select(MonthlySettlement.id).where(MonthlySettlement.settlement_month == month)
|
||
)
|
||
is None
|
||
):
|
||
raise BadRequestError("本月工资尚未生成,请随月结结清,无需单独调整")
|
||
person = session.get(Personnel, payload.personnel_id, with_for_update=True)
|
||
if person is None:
|
||
raise BadRequestError("人员不存在")
|
||
with audit_service.audit(
|
||
session,
|
||
operator=actor,
|
||
record=AuditRecord(
|
||
module="月度工资",
|
||
action="试用期结清调整",
|
||
summary=f"人员#{person.id}年度{payload.year}结清差额",
|
||
request_data=payload.model_dump(),
|
||
),
|
||
meta=meta,
|
||
):
|
||
row = next(
|
||
(r for r in preview(session, month, person.id, lock=True)["rows"] if r["year"] == payload.year),
|
||
None,
|
||
)
|
||
if row is None or row["fingerprint"] != payload.fingerprint:
|
||
raise BadRequestError("年度余额已变化,请重新查询预览")
|
||
amount = Decimal(row["delta"])
|
||
if not amount:
|
||
raise BadRequestError("没有待补发或扣回差额")
|
||
order = AdjustmentOrder(
|
||
order_no=next_no(session, model=AdjustmentOrder, column_name="order_no", prefix="TZ"),
|
||
settlement_month=month,
|
||
adjustment_type="PROBATION",
|
||
personnel_id=person.id,
|
||
personnel_name=person.personnel_name,
|
||
direction=1 if amount > 0 else 2,
|
||
amount=abs(amount),
|
||
reason=payload.reason,
|
||
submitter_id=actor.id,
|
||
submitter_name=actor.username,
|
||
attribution_json={"probation_year": payload.year, "snapshot": row},
|
||
)
|
||
session.add(order)
|
||
session.flush()
|
||
if get_settings().payroll_approval_enabled and approval_service.is_enabled(session, 3):
|
||
_wire_adjustment_approval(session, order, actor)
|
||
else:
|
||
order.status = 2
|
||
session.add(
|
||
ProbationPayment(
|
||
adjustment_order_id=order.id,
|
||
personnel_id=person.id,
|
||
year=payload.year,
|
||
month=month,
|
||
amount=amount,
|
||
snapshot_json=row,
|
||
)
|
||
)
|
||
session.flush()
|
||
out = {"order_no": order.order_no, "amount": str(amount), "month": month}
|
||
session.commit()
|
||
return out
|