Co-authored-by: Copilot <copilot@github.com>
This commit is contained in:
admin
2026-04-30 10:12:08 +09:00
parent 5026282136
commit 6669c7da77
11 changed files with 4060 additions and 295 deletions

View File

@@ -1,11 +1,26 @@
"""
賞与計算サービス (Bonus Calculation Service)
"""
from decimal import Decimal
import json
import os
import functools
from decimal import Decimal, ROUND_HALF_UP
from datetime import date
from typing import Dict, Any
from ...core.database import get_connection
_BONUS_TAX_RATES_PATH = os.path.join(
os.path.dirname(os.path.dirname(__file__)),
'config', 'bonus_tax_rates.json'
)
@functools.lru_cache(maxsize=1)
def _load_bonus_tax_rates():
"""賞与算出率表をJSONファイルから読み込むキャッシュ付き"""
with open(_BONUS_TAX_RATES_PATH, encoding='utf-8') as f:
return json.load(f)
class BonusCalculationService:
"""賞与計算サービス"""
@@ -15,8 +30,10 @@ class BonusCalculationService:
"""保険料率を取得(賞与用、年度がない場合は前年度を参照)"""
with get_connection() as conn:
with conn.cursor() as cur:
# rate_yearで年度を判定
target_year = target_date.year
# 翌月払いのため、新年度料率は5月支払分4月分から適用
# rate_year=XXXX は XXXX年5月支払〜(XXXX+1)年4月支払に適用
# 1〜4月支払は前年度year-1の料率を使用
target_year = target_date.year if target_date.month >= 5 else target_date.year - 1
cur.execute(
"""
SELECT * FROM insurance_rates
@@ -75,12 +92,12 @@ class BonusCalculationService:
"""前月までの3ヶ月平均給与を取得賞与所得税計算用"""
with get_connection() as conn:
with conn.cursor() as cur:
# 前月までの3ヶ月の総支給額を取得
# 前月までの3ヶ月の社会保険料等控除後の給与を取得
cur.execute(
"""
SELECT AVG(total_payment - commute_allowance) as avg_salary
SELECT AVG(total_payment - commute_allowance - health_insurance - care_insurance - pension_insurance - employment_insurance - COALESCE(child_support, 0)) as avg_salary
FROM (
SELECT total_payment, commute_allowance
SELECT total_payment, commute_allowance, health_insurance, care_insurance, pension_insurance, employment_insurance, child_support
FROM monthly_payroll
WHERE employee_id = %s
AND payment_date < %s
@@ -102,24 +119,34 @@ class BonusCalculationService:
) -> Decimal:
"""
賞与所得税を計算
賞与税率 = (前月給与額 - 社会保険料) × 扶養人数に応じた税率
国税庁「賞与に対する源泉徴収税額の算出率の表」甲欄を使用
bonus_amount: 社会保険料等控除後の賞与額
previous_salary_avg: 前月の社会保険料等控除後の給与額
"""
# 簡易計算: 賞与額から社会保険料概算を引いた額の10.21%を所得税とする
# 実際は前月給与から税率を求める複雑な計算が必要
if previous_salary_avg == Decimal("0"):
# 前月給与がない場合は賞与額の10.21%
return (bonus_amount * Decimal("0.1021")).quantize(Decimal("1"))
# 扶養人数による税率軽減(簡略化)
rate = Decimal("0.1021") # 基本税率 10.21%
if dependents_count == 1:
rate = Decimal("0.0820") # 8.20%
elif dependents_count == 2:
rate = Decimal("0.0619") # 6.19%
elif dependents_count >= 3:
rate = Decimal("0.0418") # 4.18%
return (bonus_amount * rate).quantize(Decimal("1"))
# 前月給与がない場合: 月額表方式が必要だがフォールバックとして10.21%
return (bonus_amount * Decimal("0.1021")).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
table = _load_bonus_tax_rates()
salary_k = float(previous_salary_avg) / 1000 # 千円単位に変換
dep_idx = min(int(dependents_count), 7)
rate_percent = Decimal("10.21") # フォールバック
for entry in table['entries']:
rng = entry['ranges'][dep_idx]
from_k = rng[0]
to_k = rng[1]
if to_k is None:
# 上限なし(最高税率帯)
if salary_k >= from_k:
rate_percent = Decimal(str(entry['rate']))
break
elif from_k <= salary_k < to_k:
rate_percent = Decimal(str(entry['rate']))
break
rate = rate_percent / Decimal("100")
return (bonus_amount * rate).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
@staticmethod
def calculate_bonus(
@@ -176,7 +203,7 @@ class BonusCalculationService:
if health_insurance_rate:
health_insurance = (
health_standard_bonus * Decimal(str(health_insurance_rate["employee_rate"]))
).quantize(Decimal("1"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
# 介護保険40歳以上65歳未満が対象
care_insurance = Decimal("0")
@@ -185,7 +212,7 @@ class BonusCalculationService:
if care_insurance_rate:
care_insurance = (
health_standard_bonus * Decimal(str(care_insurance_rate["employee_rate"]))
).quantize(Decimal("1"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
# 厚生年金
pension_insurance_rate = BonusCalculationService.get_insurance_rate("厚生年金", payment_date)
@@ -193,7 +220,7 @@ class BonusCalculationService:
if pension_insurance_rate:
pension_insurance = (
pension_standard_bonus * Decimal(str(pension_insurance_rate["employee_rate"]))
).quantize(Decimal("1"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
# 雇用保険
employment_insurance_rate = BonusCalculationService.get_insurance_rate("雇用保険", payment_date)
@@ -201,35 +228,21 @@ class BonusCalculationService:
if employment_insurance_rate:
employment_insurance = (
total_bonus * Decimal(str(employment_insurance_rate["employee_rate"]))
).quantize(Decimal("1"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
# 子ども・子育て支援金令和8年4月分以降のみ適用
# 賞与の場合: 標準報酬月額表の淨化率を使い、total_bonus × 子育て率 / 2
# 賞与の場合: 社会保険料率設定insurance_ratesの料率 × 賞与総額で計算
child_support = Decimal("0")
is_child_support_applicable = (
(bonus_year == 2026 and bonus_month >= 4) or bonus_year >= 2027
(bonus_year == 2026 and bonus_month >= 5) or bonus_year >= 2027
)
if is_child_support_applicable:
with get_connection() as conn:
with conn.cursor() as cur:
# 標準報酬月額表から子育て率を取得最上位等級rowの child_support/monthly_amountで率を計算
cur.execute(
"""
SELECT child_support, monthly_amount
FROM standard_remuneration
WHERE rate_year = %s
AND monthly_amount > 0 AND child_support > 0
ORDER BY monthly_amount DESC
LIMIT 1
""",
(payment_date.year,)
)
sr = cur.fetchone()
if sr and sr["monthly_amount"] and sr["monthly_amount"] > 0:
cs_rate = Decimal(str(sr["child_support"])) / Decimal(str(sr["monthly_amount"]))
# 標準賞与額に封上限なし(法定上賞与には標準賞与額上限なし)
child_support = (total_bonus * cs_rate / Decimal("2")).quantize(Decimal("1"))
print(f"[DEBUG] 賞与子育て支援金: 率={cs_rate}, 賞与総額={total_bonus} → 従業員負担={child_support}")
child_support_rate = BonusCalculationService.get_insurance_rate("子ども・子育て支援金", payment_date)
if child_support_rate:
child_support = (
total_bonus * Decimal(str(child_support_rate["employee_rate"]))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
print(f"[DEBUG] 賞与子育て支援金: 賞与総額={total_bonus} × 料率={child_support_rate['employee_rate']} = {child_support}")
# 所得税の計算(賞与特有の計算)
from ..calculation.service import PayrollCalculationService

View File

@@ -224,7 +224,8 @@ def recalculate_payroll(payroll_id: int, request: schemas.RecalculateRequest = N
UPDATE monthly_payroll SET
base_salary = %s, overtime_pay = %s, holiday_pay = %s,
commute_allowance = %s, total_payment = %s,
health_insurance = %s, pension_insurance = %s, employment_insurance = %s,
health_insurance = %s, care_insurance = %s, pension_insurance = %s,
employment_insurance = %s, child_support = %s,
income_tax = %s, total_deduction = %s, net_payment = %s,
calculated_at = %s, calculated_by = %s
WHERE payroll_id = %s
@@ -233,8 +234,9 @@ def recalculate_payroll(payroll_id: int, request: schemas.RecalculateRequest = N
(
calc_result["base_salary"], calc_result["overtime_pay"], calc_result["holiday_pay"],
calc_result["commute_allowance"], calc_result["total_payment"],
calc_result["health_insurance"], calc_result["pension_insurance"],
calc_result["employment_insurance"],
calc_result["health_insurance"], calc_result["care_insurance"],
calc_result["pension_insurance"], calc_result["employment_insurance"],
calc_result["child_support"],
calc_result["income_tax"], calc_result["total_deduction"], calc_result["net_payment"],
datetime.now(), calc_result["calculated_by"],
payroll_id

View File

@@ -84,6 +84,7 @@ class MonthlyPayroll(MonthlyPayrollBase):
care_insurance: Decimal
pension_insurance: Decimal
employment_insurance: Decimal
child_support: Optional[Decimal] = None
income_tax: Decimal
resident_tax: Decimal
other_deduction: Decimal

View File

@@ -1,7 +1,7 @@
"""
給与計算サービス (Payroll Calculation Service)
"""
from decimal import Decimal
from decimal import Decimal, ROUND_HALF_UP
from datetime import date
from typing import Dict, Any
from ...core.database import get_connection
@@ -58,8 +58,10 @@ class PayrollCalculationService:
"""保険料率を取得(年度がない場合は前年度を参照)"""
with get_connection() as conn:
with conn.cursor() as cur:
# rate_yearで年度を判定
target_year = target_date.year
# 翌月払いのため、新年度料率は5月支払分4月分から適用
# rate_year=XXXX は XXXX年5月支払〜(XXXX+1)年4月支払に適用
# 1〜4月支払は前年度year-1の料率を使用
target_year = target_date.year if target_date.month >= 5 else target_date.year - 1
cur.execute(
"""
SELECT * FROM insurance_rates
@@ -300,7 +302,7 @@ class PayrollCalculationService:
# まず指定年度の標準報酬月額表を検索
cur.execute(
"""
SELECT monthly_amount, child_support
SELECT monthly_amount
FROM standard_remuneration
WHERE rate_year = %s
AND salary_from <= %s
@@ -318,7 +320,7 @@ class PayrollCalculationService:
previous_year = target_year - year_offset
cur.execute(
"""
SELECT monthly_amount, child_support
SELECT monthly_amount
FROM standard_remuneration
WHERE rate_year = %s
AND salary_from <= %s
@@ -334,11 +336,9 @@ class PayrollCalculationService:
if result:
standard_monthly_salary = Decimal(str(result["monthly_amount"]))
child_support_full = Decimal(str(result["child_support"])) if result["child_support"] is not None else Decimal("0")
print(f"[DEBUG] 標準報酬月額表検索: 総支給額={total_payment} → 標準報酬月額={standard_monthly_salary}, 子育て支援金全額={child_support_full}")
print(f"[DEBUG] 標準報酬月額表検索: 総支給額={total_payment} → 標準報酬月額={standard_monthly_salary}")
else:
print(f"[DEBUG] 標準報酬月額表検索: 該当等級なし(総支給額={total_payment}")
child_support_full = Decimal("0")
# 社会保険料上限を取得
@@ -381,7 +381,7 @@ class PayrollCalculationService:
if health_insurance_rate:
health_insurance = (
health_standard_salary * Decimal(str(health_insurance_rate["employee_rate"]))
).quantize(Decimal("0.01"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
elif calc_social_insurance:
errors.append(f"健康保険料率が見つかりません(年度: {payment_date.year}")
@@ -392,7 +392,7 @@ class PayrollCalculationService:
if care_insurance_rate:
care_insurance = (
health_standard_salary * Decimal(str(care_insurance_rate["employee_rate"]))
).quantize(Decimal("0.01"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
print(f"[DEBUG] 介護保険計算: {health_standard_salary} × {care_insurance_rate['employee_rate']}% = {care_insurance}")
else:
errors.append(f"介護保険料率が見つかりません(年度: {payment_date.year}") if calc_social_insurance else None
@@ -403,7 +403,7 @@ class PayrollCalculationService:
if pension_insurance_rate:
pension_insurance = (
pension_standard_salary * Decimal(str(pension_insurance_rate["employee_rate"]))
).quantize(Decimal("0.01"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
print(f"[DEBUG] 厚生年金計算: {pension_standard_salary} × {pension_insurance_rate['employee_rate']}% = {pension_insurance}")
elif calc_social_insurance:
errors.append(f"厚生年金保険料率が見つかりません(年度: {payment_date.year}")
@@ -412,14 +412,19 @@ class PayrollCalculationService:
employment_insurance_rate = PayrollCalculationService.get_insurance_rate("雇用保険", payment_date)
employment_insurance = Decimal("0")
# 子ども・子育て支援金令和8年4月分以降のみ適用
# 子ども・子育て支援金令和8年4月分5月納付分以降のみ適用)
# 翌月払いのため、payroll_month=55月支払が4月分保険料の初回控除月
child_support = Decimal("0")
is_child_support_applicable = (
(payroll_year == 2026 and payroll_month >= 4) or payroll_year >= 2027
(payroll_year == 2026 and payroll_month >= 5) or payroll_year >= 2027
)
if is_child_support_applicable and calc_social_insurance:
child_support = (child_support_full / Decimal("2")).quantize(Decimal("0.01"))
print(f"[DEBUG] 子育て支援金: 全額={child_support_full} → 折半額(従業員負担)={child_support}")
child_support_rate = PayrollCalculationService.get_insurance_rate("子ども・子育て支援金", payment_date)
if child_support_rate:
child_support = (
standard_monthly_salary * Decimal(str(child_support_rate["employee_rate"]))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
print(f"[DEBUG] 子育て支援金: 標準報酬月額={standard_monthly_salary} × 料率={child_support_rate['employee_rate']} = {child_support}")
# 雇用保険加入対象かどうか確認
is_employment_insurance_eligible = salary_setting.get("employment_insurance_eligible", True) if salary_setting else True
@@ -427,7 +432,7 @@ class PayrollCalculationService:
if is_employment_insurance_eligible and employment_insurance_rate:
employment_insurance = (
total_payment * Decimal(str(employment_insurance_rate["employee_rate"]))
).quantize(Decimal("0.01"))
).quantize(Decimal("1"), rounding=ROUND_HALF_UP)
elif not is_employment_insurance_eligible:
print(f"[DEBUG] この従業員は雇用保険非対象です")
elif not employment_insurance_rate and calc_social_insurance:

View File

@@ -0,0 +1,280 @@
{
"version": "令和2年分令和2年4月1日以降適用",
"source": "国税庁 賞与に対する源泉徴収税額の算出率の表甲欄平成24年3月31日財務省告示第115号別表第三平成31年3月29日財務省告示第97号改正",
"note": "単位は千円。rate は%値。ranges[i] は扶養i人の[以上(千円), 未満(千円) または null=上限なし]。賞与の金額は社会保険料等控除後の金額に適用する。",
"entries": [
{
"rate": 0.0,
"ranges": [
[0, 68],
[0, 94],
[0, 133],
[0, 171],
[0, 210],
[0, 243],
[0, 275],
[0, 308]
]
},
{
"rate": 2.042,
"ranges": [
[68, 79],
[94, 243],
[133, 269],
[171, 295],
[210, 300],
[243, 300],
[275, 333],
[308, 372]
]
},
{
"rate": 4.084,
"ranges": [
[79, 252],
[243, 282],
[269, 312],
[295, 345],
[300, 378],
[300, 406],
[333, 431],
[372, 456]
]
},
{
"rate": 6.126,
"ranges": [
[252, 300],
[282, 338],
[312, 369],
[345, 398],
[378, 424],
[406, 450],
[431, 476],
[456, 502]
]
},
{
"rate": 8.168,
"ranges": [
[300, 334],
[338, 365],
[369, 393],
[398, 417],
[424, 444],
[450, 472],
[476, 499],
[502, 523]
]
},
{
"rate": 10.21,
"ranges": [
[334, 363],
[365, 394],
[393, 420],
[417, 445],
[444, 470],
[472, 496],
[499, 521],
[523, 545]
]
},
{
"rate": 12.252,
"ranges": [
[363, 395],
[394, 422],
[420, 450],
[445, 477],
[470, 503],
[496, 525],
[521, 547],
[545, 571]
]
},
{
"rate": 14.294,
"ranges": [
[395, 426],
[422, 455],
[450, 484],
[477, 510],
[503, 534],
[525, 557],
[547, 582],
[571, 607]
]
},
{
"rate": 16.336,
"ranges": [
[426, 520],
[455, 520],
[484, 520],
[510, 544],
[534, 570],
[557, 597],
[582, 623],
[607, 650]
]
},
{
"rate": 18.378,
"ranges": [
[520, 601],
[520, 617],
[520, 632],
[544, 647],
[570, 662],
[597, 677],
[623, 693],
[650, 708]
]
},
{
"rate": 20.42,
"ranges": [
[601, 678],
[617, 699],
[632, 721],
[647, 745],
[662, 768],
[677, 792],
[693, 815],
[708, 838]
]
},
{
"rate": 22.462,
"ranges": [
[678, 708],
[699, 733],
[721, 757],
[745, 782],
[768, 806],
[792, 831],
[815, 856],
[838, 880]
]
},
{
"rate": 24.504,
"ranges": [
[708, 745],
[733, 771],
[757, 797],
[782, 823],
[806, 849],
[831, 875],
[856, 900],
[880, 926]
]
},
{
"rate": 26.546,
"ranges": [
[745, 788],
[771, 814],
[797, 841],
[823, 868],
[849, 896],
[875, 923],
[900, 950],
[926, 978]
]
},
{
"rate": 28.588,
"ranges": [
[788, 846],
[814, 874],
[841, 902],
[868, 931],
[896, 959],
[923, 987],
[950, 1015],
[978, 1043]
]
},
{
"rate": 30.63,
"ranges": [
[846, 914],
[874, 944],
[902, 975],
[931, 1005],
[959, 1036],
[987, 1066],
[1015, 1096],
[1043, 1127]
]
},
{
"rate": 32.672,
"ranges": [
[914, 1312],
[944, 1336],
[975, 1360],
[1005, 1385],
[1036, 1409],
[1066, 1434],
[1096, 1458],
[1127, 1482]
]
},
{
"rate": 35.735,
"ranges": [
[1312, 1521],
[1336, 1526],
[1360, 1526],
[1385, 1538],
[1409, 1555],
[1434, 1555],
[1458, 1555],
[1482, 1583]
]
},
{
"rate": 38.798,
"ranges": [
[1521, 2621],
[1526, 2645],
[1526, 2669],
[1538, 2693],
[1555, 2716],
[1555, 2740],
[1555, 2764],
[1583, 2788]
]
},
{
"rate": 41.861,
"ranges": [
[2621, 3495],
[2645, 3527],
[2669, 3559],
[2693, 3590],
[2716, 3622],
[2740, 3654],
[2764, 3685],
[2788, 3717]
]
},
{
"rate": 45.945,
"ranges": [
[3495, null],
[3527, null],
[3559, null],
[3590, null],
[3622, null],
[3654, null],
[3685, null],
[3717, null]
]
}
]
}

File diff suppressed because it is too large Load Diff