基于 skill-template 脚手架开发,包含: - payroll_config(费率配置,可编辑)+ payslip_snapshots(解析历史,只读)2 张业务表 - 全国默认 + 北上广 4 套费率(社保/公积金/个税起征点/7 级税率表) - 5 个业务 Action:calculate-payslip-simple / calculate-payslip-full / explain-term / view-config / edit-config - 个税累计预扣法计算,支持多月历史累加 - 20 个薪资名词库(社保/公积金/个税/全勤/加班费等) - 完整市场文档(README/TUTORIAL/DEMO/CHANGELOG) - 296 测试全部通过
329 lines
11 KiB
Python
329 lines
11 KiB
Python
"""工资条核算引擎(唯一业务内核)。
|
||
|
||
职责:
|
||
- 接收结构化字段(应发项、扣除项、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()
|