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
|