"""工资条核算引擎(唯一业务内核)。 职责: - 接收结构化字段(应发项、扣除项、period、自报实发) - 确定性核算:应发合计 / 扣除合计 / 实发 / 核对差异 - 缺扣除项时按费率估算(社保/公积金/个税累计预扣) - 落库到 payslip_snapshots(含完整明细 JSON) 不做:LLM 文本解析、自然语言生成。 """ from __future__ import annotations import json import time from typing import Any, Optional from db.connection import get_conn, init_db from service.config_service import get_config_value from service.tax_service import compute_cumulative_tax from util.constants import SKILL_SLUG from util.logging_config import get_skill_logger def _clamp_base(base: float, lower: float, upper: float) -> float: """按上下限裁剪缴费基数。""" if base < lower: return lower if base > upper: return upper return base def _estimate_social(base_salary: float, region: str = "default") -> dict[str, Any]: """按费率估算社保个人部分。""" pension_rate = float(get_config_value("social_pension_rate", region) or 0.08) medical_rate = float(get_config_value("social_medical_rate", region) or 0.02) unemploy_rate = float(get_config_value("social_unemployment_rate", region) or 0.005) base_min = float(get_config_value("social_base_min", region) or 5000) base_max = float(get_config_value("social_base_max", region) or 30000) base = _clamp_base(base_salary, base_min, base_max) pension = round(base * pension_rate, 2) medical = round(base * medical_rate, 2) unemployment = round(base * unemploy_rate, 2) personal_total = round(pension + medical + unemployment, 2) return { "base_used": round(base, 2), "pension": pension, "medical": medical, "unemployment": unemployment, "personal_total": personal_total, "estimated": True, } def _estimate_housing_fund(base_salary: float, region: str = "default") -> dict[str, Any]: """按费率估算公积金个人部分。""" rate = float(get_config_value("housing_fund_rate", region) or 0.07) base_min = float(get_config_value("housing_fund_base_min", region) or 2500) base_max = float(get_config_value("housing_fund_base_max", region) or 30000) base = _clamp_base(base_salary, base_min, base_max) personal = round(base * rate, 2) return { "base_used": round(base, 2), "personal_rate": rate, "personal": personal, "estimated": True, } def calculate_payslip( *, base_salary: float = 0.0, performance: float = 0.0, full_attendance: float = 0.0, meal_allowance: float = 0.0, transport_allowance: float = 0.0, overtime_pay: float = 0.0, other_gross: float = 0.0, # 扣除项(可选,None 表示缺值→估算) social_personal: Optional[float] = None, housing_fund_personal: Optional[float] = None, tax: Optional[float] = None, leave_deduction: float = 0.0, other_deduction: float = 0.0, period: str = "", reported_net: Optional[float] = None, raw_payload: str = "", region: str = "default", ) -> dict[str, Any]: """工资条核算入口。 缺扣除项时按费率估算;估算项标 estimated=True,note 附"仅供参考"。 落库到 payslip_snapshots,返回结构化 JSON。 """ log = get_skill_logger() log.info( "payslip_calculate_start period=%s base=%s reported_net=%s region=%s", period, base_salary, reported_net, region, ) # 确保 DB 已初始化(agent/CLI 都可能首次调用) init_db() # 应发合计 bonus_total = round(performance + full_attendance, 2) allowance_total = round(meal_allowance + transport_allowance, 2) gross_total = round( base_salary + performance + full_attendance + meal_allowance + transport_allowance + overtime_pay + other_gross, 2, ) # 扣除项:缺失则估算 estimated = False social_detail: dict[str, Any] if social_personal is None: social_detail = _estimate_social(base_salary, region) estimated = True else: social_detail = {"personal_total": round(float(social_personal), 2), "estimated": False} housing_detail: dict[str, Any] if housing_fund_personal is None: housing_detail = _estimate_housing_fund(base_salary, region) estimated = True else: housing_detail = {"personal": round(float(housing_fund_personal), 2), "estimated": False} # 个税:缺失则按累计预扣估算 tax_detail: dict[str, Any] if tax is None: social_for_tax = float(social_detail.get("personal_total", 0)) housing_for_tax = float(housing_detail.get("personal", 0)) tax_result = compute_cumulative_tax( current_gross=gross_total, period=period or "1970-01", current_social=social_for_tax, current_housing_fund=housing_for_tax, region=region, ) tax_detail = { "actual": tax_result["actual_tax"], "cumulative_taxable": tax_result["cumulative_taxable"], "brackets_used": tax_result["brackets_used"], "has_history": tax_result["has_history"], "estimated": True, "note": tax_result["note"], } estimated = True else: tax_detail = {"actual": round(float(tax), 2), "estimated": False} social_total = float(social_detail.get("personal_total", 0)) housing_total = float(housing_detail.get("personal", 0)) tax_total = float(tax_detail.get("actual", 0)) leave_total = float(leave_deduction) other_ded_total = float(other_deduction) deduction_total = round( social_total + housing_total + tax_total + leave_total + other_ded_total, 2 ) net_salary = round(gross_total - deduction_total, 2) # 核对 check: dict[str, Any] = {"reported": None, "calculated": net_salary, "match": None, "diff": None} if reported_net is not None: reported_net = round(float(reported_net), 2) diff = round(reported_net - net_salary, 2) check = { "reported": reported_net, "calculated": net_salary, "match": abs(diff) < 0.01, "diff": diff, } # 完整明细(供历史查询与个税累计) detail = { "gross": { "base_salary": base_salary, "performance": performance, "full_attendance": full_attendance, "meal_allowance": meal_allowance, "transport_allowance": transport_allowance, "overtime_pay": overtime_pay, "other_gross": other_gross, "bonus_total": bonus_total, "allowance_total": allowance_total, "total": gross_total, }, "deduction": { "social": social_detail, "housing_fund": housing_detail, "tax": tax_detail, "leave": leave_total, "other": other_ded_total, "total": deduction_total, }, "net": net_salary, "check": check, "region": region, } estimates = [] if estimated: notes = [] if social_detail.get("estimated"): estimates.append({"item": "社保个人", "value": social_total, "note": "按费率估算,仅供参考"}) if housing_detail.get("estimated"): estimates.append({"item": "公积金个人", "value": housing_total, "note": "按费率估算,仅供参考"}) if tax_detail.get("estimated"): estimates.append({"item": "个税", "value": tax_total, "note": tax_detail.get("note", "仅供参考")}) if not estimates: notes.append("所有扣除项均已由用户给定,无估算") else: estimates.append({"item": "无估算", "value": 0, "note": "所有扣除项均由用户给定"}) # 落库 snapshot_date = int(time.time()) conn = get_conn() snapshot_id = None try: cur = conn.cursor() cur.execute( """ INSERT INTO payslip_snapshots ( snapshot_date, period, base_salary, gross_total, deduction_total, net_salary, reported_net, check_match, check_diff, detail_json, raw_payload ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, ( snapshot_date, period or None, base_salary, gross_total, deduction_total, net_salary, reported_net, int(check["match"]) if check["match"] is not None else None, check["diff"], json.dumps(detail, ensure_ascii=False), raw_payload or None, ), ) snapshot_id = cur.lastrowid conn.commit() finally: conn.close() log.info( "payslip_calculate_done period=%s gross=%s deduction=%s net=%s match=%s snapshot_id=%s", period, gross_total, deduction_total, net_salary, check["match"], snapshot_id, ) return { "ok": True, "skill": SKILL_SLUG, "period": period, "gross": detail["gross"], "deduction": detail["deduction"], "net": net_salary, "check": check, "estimates": estimates, "snapshot_id": snapshot_id, } def calculate_payslip_simple( *, base_salary: float, period: str, reported_net: Optional[float] = None, region: str = "default", ) -> dict[str, Any]: """快速估算:仅基本工资+period,按费率估全部扣除项。""" return calculate_payslip( base_salary=base_salary, period=period, reported_net=reported_net, region=region, ) def list_snapshots(period: str | None = None, limit: int = 50) -> list[dict[str, Any]]: """列出解析历史(供数据管理 / CLI 查看)。""" conn = get_conn() try: cur = conn.cursor() if period: cur.execute( """ SELECT id, snapshot_date, period, base_salary, gross_total, deduction_total, net_salary, reported_net, check_match, check_diff FROM payslip_snapshots WHERE period=? ORDER BY snapshot_date DESC LIMIT ? """, (period, limit), ) else: cur.execute( """ SELECT id, snapshot_date, period, base_salary, gross_total, deduction_total, net_salary, reported_net, check_match, check_diff FROM payslip_snapshots ORDER BY snapshot_date DESC LIMIT ? """, (limit,), ) rows = cur.fetchall() return [ { "id": r[0], "snapshot_date": r[1], "period": r[2], "base_salary": r[3], "gross_total": r[4], "deduction_total": r[5], "net_salary": r[6], "reported_net": r[7], "check_match": r[8], "check_diff": r[9], } for r in rows ] finally: conn.close()