"""高校人事请假考勤:领域服务(核算引擎与报告生成)。""" from __future__ import annotations import calendar import html from datetime import date, datetime from typing import Any, Optional from db import leave_repository as repo from service.employee_import_service import IMPORT_SUBDIR, ensure_builtin_samples from service.html_page_service import ( HOST_HTML_RENDER_ACTION_ID, load_html_template, render_html_placeholders, write_html_page, ) from util.logging_config import get_skill_logger from util.timeutil import now_unix def _log(): try: return get_skill_logger() except RuntimeError: return None def ensure_ready() -> None: ensure_builtin_samples(overwrite=False) def _parse_date(text: str) -> date: try: return datetime.strptime(text.strip(), "%Y-%m-%d").date() except (ValueError, AttributeError) as exc: raise ValueError(f"日期格式无效:{text!r}(需 YYYY-MM-DD)") from exc def _load_holiday_dates() -> set[str]: holidays = repo.list_holidays() return {h["holiday_date"] for h in holidays if h.get("holiday_date")} def _count_working_days(start: date, end: date, holiday_dates: set[str]) -> int: count = 0 cur = start while cur <= end: iso = cur.isoformat() if cur.weekday() < 5 and iso not in holiday_dates: count += 1 cur = date.fromordinal(cur.toordinal() + 1) return count def _working_days_in_month(d: date, holiday_dates: set[str]) -> int: _, last_day = calendar.monthrange(d.year, d.month) count = 0 for day in range(1, last_day + 1): cur = date(d.year, d.month, day) iso = cur.isoformat() if cur.weekday() < 5 and iso not in holiday_dates: count += 1 return count def _determine_approval_level( working_days: int, policy: dict[str, Any], leave_type: str ) -> str: if leave_type in ("产假", "育假"): return "系主任+人事处+校领导" threshold_dept = int(policy.get("approval_threshold_dept", 3)) threshold_hr = int(policy.get("approval_threshold_hr", 10)) if working_days <= threshold_dept: return "系主任" if working_days <= threshold_hr: return "系主任+人事处" return "系主任+人事处+校领导" def calculate_leave( employee_id: int, *, leave_type: str, start_date: str, end_date: str, has_certificate: bool = False, certificate_type: Optional[str] = None, ) -> dict[str, Any]: ensure_ready() employee = repo.get_employee(int(employee_id)) if not employee: raise ValueError(f"找不到教职工 id={employee_id}。请先在「教职工」表中新增或导入。") policy = repo.get_policy(leave_type) if not policy: raise ValueError( f"未知请假类型:{leave_type}。请在「请假政策」表中配置该类型后重试。" ) if int(policy.get("requires_certificate", 0)) and not has_certificate: raise ValueError(f"请假类型「{leave_type}」需附证明文件,请提供后重试。") start = _parse_date(start_date) end = _parse_date(end_date) if end < start: raise ValueError("结束日期不能早于开始日期。") natural_days = (end - start).days + 1 holiday_dates = _load_holiday_dates() working_days = _count_working_days(start, end, holiday_dates) day_basis = str(policy.get("day_basis", "workday")) billable_days = natural_days if day_basis == "natural" else working_days monthly_salary = float(employee.get("monthly_salary", 0) or 0) working_days_in_month = _working_days_in_month(start, holiday_dates) if working_days_in_month <= 0: working_days_in_month = 21.75 daily_salary = monthly_salary / working_days_in_month if working_days_in_month else 0.0 paid_days = float(policy.get("paid_days", 0) or 0) partial_ratio = float(policy.get("partial_pay_ratio", 1.0) or 1.0) unpaid_after = policy.get("unpaid_after_days") unpaid_after_days = float(unpaid_after) if unpaid_after is not None else None if billable_days <= paid_days: deduction_amount = 0.0 elif unpaid_after_days is not None and billable_days > unpaid_after_days: partial_days = max(unpaid_after_days - paid_days, 0) unpaid_days = billable_days - unpaid_after_days deduction_amount = ( daily_salary * partial_days * (1 - partial_ratio) + daily_salary * unpaid_days ) else: partial_days = billable_days - paid_days deduction_amount = daily_salary * partial_days * (1 - partial_ratio) annual_leave_used = 0.0 remaining_annual_leave = float(employee.get("annual_leave_remaining", 0) or 0) if int(policy.get("can_offset_annual", 0)) and remaining_annual_leave > 0: offset_days = min(billable_days, remaining_annual_leave) deduction_amount = max(deduction_amount - offset_days * daily_salary, 0.0) annual_leave_used = offset_days remaining_annual_leave = remaining_annual_leave - offset_days approval_level = _determine_approval_level(working_days, policy, leave_type) params = { "employee_id": employee_id, "leave_type": leave_type, "start_date": start_date, "end_date": end_date, "has_certificate": has_certificate, "certificate_type": certificate_type, "policy_id": policy.get("id"), "day_basis": day_basis, } request_id = repo.create_leave_request( { "employee_id": employee_id, "leave_type": leave_type, "start_date": start_date, "end_date": end_date, "has_certificate": 1 if has_certificate else 0, "certificate_type": certificate_type, "natural_days": natural_days, "working_days": working_days, "monthly_salary": monthly_salary, "working_days_in_month": working_days_in_month, "daily_salary": round(daily_salary, 2), "deduction_amount": round(deduction_amount, 2), "annual_leave_used": annual_leave_used, "remaining_annual_leave": remaining_annual_leave, "approval_level": approval_level, "status": "draft", "params": params, "summary": f"{employee.get('name')}「{leave_type}」{start_date}~{end_date}({working_days}工作日)核算中…", } ) try: summary = ( f"{employee.get('name')}「{leave_type}」{start_date}~{end_date}" f"({natural_days}自然日/{working_days}工作日)" f"应扣 {round(deduction_amount, 2)} 元,审批:{approval_level}" ) repo.update_leave_request( request_id, status="completed", summary=summary, ) if annual_leave_used > 0: repo.update_employee_annual_leave(employee_id, remaining_annual_leave) balance_type = _map_balance_type(leave_type) existing = repo.get_balance(employee_id, balance_type) used = float(existing["used_days"]) if existing else 0.0 repo.upsert_balance( { "employee_id": employee_id, "balance_type": balance_type, "total_days": policy.get("max_days"), "used_days": used + billable_days, "remaining_days": ( float(policy.get("max_days", 0) or 0) - used - billable_days if policy.get("max_days") is not None else None ), } ) except Exception as exc: repo.update_leave_request( request_id, status="failed", error_message=str(exc), ) raise payload = { "ok": True, "request_id": request_id, "employee_id": employee_id, "employee_name": employee.get("name"), "department": employee.get("department"), "leave_type": leave_type, "start_date": start_date, "end_date": end_date, "natural_days": natural_days, "working_days": working_days, "monthly_salary": monthly_salary, "working_days_in_month": working_days_in_month, "daily_salary": round(daily_salary, 2), "deduction_amount": round(deduction_amount, 2), "annual_leave_used": annual_leave_used, "remaining_annual_leave": remaining_annual_leave, "approval_level": approval_level, "certificate_type": certificate_type, "message": summary, } log = _log() if log: log.info( "leave_calculated request_id=%s employee_id=%s deduction=%s approval=%s", request_id, employee_id, round(deduction_amount, 2), approval_level, ) return payload def _map_balance_type(leave_type: str) -> str: mapping = { "病假": "sick", "长期病假": "sick", "事假": "annual", "婚假": "marriage", "产假": "maternity", "育假": "maternity", "丧假": "bereavement", } return mapping.get(leave_type, "annual") def build_leave_report_html(request_id: int) -> str: req = repo.get_request(int(request_id)) if not req: raise ValueError(f"找不到请假记录 id={request_id}。") employee = repo.get_employee(int(req["employee_id"])) if req.get("employee_id") else None name = html.escape(str(employee.get("name", "")) if employee else "") department = html.escape(str(employee.get("department", "")) if employee else "") position = html.escape(str(employee.get("position", "")) if employee else "") rows = [ ("申请人", f"{name}({department})"), ("岗位", position or "—"), ("请假类型", html.escape(str(req.get("leave_type", "")))), ("请假日期", f"{req.get('start_date', '')} 至 {req.get('end_date', '')}(共 {req.get('natural_days', 0)} 个自然日)"), ("实际工作日", f"{req.get('working_days', 0)} 天"), ("当月应出勤", f"{req.get('working_days_in_month', 0)} 天"), ("日薪", f"{req.get('daily_salary', 0)} 元"), ("应扣工资", f"{req.get('deduction_amount', 0)} 元"), ("已抵年假", f"{req.get('annual_leave_used', 0)} 天"), ("剩余年假", f"{req.get('remaining_annual_leave', 0)} 天"), ("审批层级", html.escape(str(req.get("approval_level", "")))), ("证件类型", html.escape(str(req.get("certificate_type") or "无"))), ("状态", html.escape(str(req.get("status", "")))), ] rows_html = "".join( f"
{approval_hint}
已自动生成审批表,需附相关证件原件。