PayrollManagementSystem/backend/app/services/probation_service.py

321 lines
12 KiB
Python
Raw Normal View History

"""试用期计提、缓发和年度结清;支付受原业务起付及平方权重约束。"""
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