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