Files
query-payslip-default/scripts/service/payslip_service.py
skill-dev 3db196e49d feat: 工资查询助手首版 v1.0.105
基于 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 测试全部通过
2026-09-07 17:02:05 +08:00

329 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""工资条核算引擎(唯一业务内核)。
职责:
- 接收结构化字段(应发项、扣除项、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()