Files
query-payslip-default/scripts/service/tax_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

162 lines
5.8 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.
"""个税累计预扣计算服务。
按"累计预扣法"计算当月个税:
累计预扣预缴应纳税所得额 = 累计收入 - 累计减除费用 - 累计专项扣除 - 累计专项附加扣除 - 累计依法确定的其他扣除
当月应预扣预缴税额 = (累计预扣预缴应纳税所得额 × 预扣率 - 速算扣除数) - 已预扣预缴税额
简化假设:
- 专项附加扣除与其他扣除暂不计入(数据不可得)
- 累计减除费用 = 5000 × 当年至本月份数
- 累计专项扣除 = 累计社保+公积金个人部分(从历史 snapshots 读取)
"""
from __future__ import annotations
import time
from typing import Any
from service.config_service import get_config_value, get_tax_brackets
from db.connection import get_conn
from util.logging_config import get_skill_logger
log = get_skill_logger()
def _month_index(period: str) -> int:
"""从 'YYYY-MM' 提取月份数(1-12)。"""
try:
return int(period.split("-")[1])
except (ValueError, IndexError):
return 1
def _year_of_period(period: str) -> str:
"""从 'YYYY-MM' 提取年份。"""
try:
return period.split("-")[0]
except (ValueError, IndexError):
return "1970"
def get_cumulative_deduction_before(period: str) -> dict[str, float]:
"""读取同一年的历史 snapshots,累计社保/公积金个人部分。
period 之前的同一年 snapshots(按 period 字符串排序 < 当前 period)。
返回 {social_cum, housing_fund_cum, tax_cum}。
"""
year = _year_of_period(period)
conn = get_conn()
try:
cur = conn.cursor()
cur.execute(
"""
SELECT detail_json FROM payslip_snapshots
WHERE period LIKE ? AND period < ?
ORDER BY period ASC
""",
(f"{year}-%", period),
)
import json
social_cum = 0.0
housing_fund_cum = 0.0
tax_cum = 0.0
for (detail_str,) in cur.fetchall():
if not detail_str:
continue
try:
detail = json.loads(detail_str)
except (json.JSONDecodeError, TypeError):
continue
ded = detail.get("deduction", {}) if isinstance(detail, dict) else {}
social_cum += float(ded.get("social", {}).get("personal_total", 0) or 0)
housing_fund_cum += float(ded.get("housing_fund", {}).get("personal", 0) or 0)
tax_cum += float(ded.get("tax", {}).get("actual", 0) or 0)
return {"social_cum": social_cum, "housing_fund_cum": housing_fund_cum, "tax_cum": tax_cum}
finally:
conn.close()
def compute_cumulative_tax(
*,
current_gross: float,
period: str,
current_social: float,
current_housing_fund: float,
region: str = "default",
) -> dict[str, Any]:
"""按累计预扣法计算当月个税。
返回 {
actual_tax: float, # 本次应预扣个税
cumulative_income: float,
cumulative_deduction: float,
cumulative_taxable: float,
cumulative_tax: float, # 累计应预扣
prior_tax_paid: float, # 之前已预扣
brackets_used: dict, # 命中的税率档
has_history: bool, # 是否有历史累计数据
note: str,
}
"""
log.info(
"tax_compute_start period=%s gross=%s social=%s housing=%s region=%s",
period, current_gross, current_social, current_housing_fund, region,
)
threshold = get_config_value("tax_threshold", region) or 5000
brackets = get_tax_brackets(region)
month_idx = _month_index(period)
cumulative_threshold = float(threshold) * month_idx
prior = get_cumulative_deduction_before(period)
has_history = prior["social_cum"] > 0 or prior["tax_cum"] > 0
cumulative_income = current_gross + prior.get("income_cum", 0.0) if has_history else current_gross
# 简化:累计专项扣除 = 累计社保+公积金个人
cumulative_special_deduction = (
prior["social_cum"] + prior["housing_fund_cum"]
+ current_social + current_housing_fund
)
cumulative_taxable = cumulative_income - cumulative_threshold - cumulative_special_deduction
if cumulative_taxable < 0:
cumulative_taxable = 0.0
cumulative_tax = 0.0
bracket_hit = None
if brackets and cumulative_taxable > 0:
for bracket in brackets:
upper = bracket.get("upper")
rate = float(bracket.get("rate", 0))
quick = float(bracket.get("quick_deduction", 0))
if upper is None or cumulative_taxable <= float(upper):
cumulative_tax = cumulative_taxable * rate - quick
bracket_hit = bracket
break
if bracket_hit is None and brackets:
bracket_hit = brackets[-1]
rate = float(bracket_hit.get("rate", 0))
quick = float(bracket_hit.get("quick_deduction", 0))
cumulative_tax = cumulative_taxable * rate - quick
cumulative_tax = max(0.0, cumulative_tax)
prior_tax_paid = prior["tax_cum"] if has_history else 0.0
actual_tax = max(0.0, cumulative_tax - prior_tax_paid)
note = "仅供参考,非官方数据"
if not has_history:
note = "仅供参考(无累计历史数据,累计按本月单月计算),非官方数据"
return {
"actual_tax": round(actual_tax, 2),
"cumulative_income": round(cumulative_income, 2),
"cumulative_threshold": round(cumulative_threshold, 2),
"cumulative_special_deduction": round(cumulative_special_deduction, 2),
"cumulative_taxable": round(cumulative_taxable, 2),
"cumulative_tax": round(cumulative_tax, 2),
"prior_tax_paid": round(prior_tax_paid, 2),
"brackets_used": bracket_hit,
"has_history": has_history,
"note": note,
}