diff --git a/backend/app/main.py b/backend/app/main.py index 01742aa..4fa8b61 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -97,6 +97,9 @@ app.include_router(cash.router) from app.routers import file_upload app.include_router(file_upload.router) +from app.modules.egov.router import router as egov_router +app.include_router(egov_router) + # ───────────────────────── # 給与モジュール (Payroll Module) # ───────────────────────── @@ -118,6 +121,9 @@ app.include_router(payroll_bonus_router) from app.payroll.vouchers.router import router as payroll_vouchers_router app.include_router(payroll_vouchers_router) +from app.payroll.withholding_slip.router import router as withholding_slip_router +app.include_router(withholding_slip_router) + from fastapi.staticfiles import StaticFiles import os diff --git a/backend/app/payroll/withholding_slip/__init__.py b/backend/app/payroll/withholding_slip/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/payroll/withholding_slip/router.py b/backend/app/payroll/withholding_slip/router.py new file mode 100644 index 0000000..7c84280 --- /dev/null +++ b/backend/app/payroll/withholding_slip/router.py @@ -0,0 +1,71 @@ +""" +給与所得の源泉徴収票 APIルーター +""" +from fastapi import APIRouter, HTTPException, Query +from typing import List, Optional +import logging + +from .schemas import WithholdingSlipRequest, WithholdingSlipResponse +from .service import get_withholding_slips +from app.core.database import get_connection + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/payroll/withholding-slip", tags=["Payroll - Withholding Slip"]) + + +@router.post("/generate", response_model=WithholdingSlipResponse) +def generate_withholding_slips(request: WithholdingSlipRequest): + """ + 指定年度・従業員の給与所得の源泉徴収票データを生成する + """ + if not request.employee_ids: + raise HTTPException(status_code=400, detail="従業員IDを指定してください") + if request.tax_year < 2000 or request.tax_year > 2099: + raise HTTPException(status_code=400, detail="年度が不正です") + + try: + slips = get_withholding_slips(request.tax_year, request.employee_ids) + return WithholdingSlipResponse( + tax_year=request.tax_year, + slips=slips, + total_count=len(slips) + ) + except Exception as e: + logger.error(f"源泉徴収票生成エラー: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/employees") +def get_employees_for_slip(): + """アクティブな従業員リストを取得(源泉徴収票用)""" + try: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute(""" + SELECT employee_id, employee_code, name, name_kana + FROM employees + WHERE is_active = true + ORDER BY employee_code + """) + rows = cur.fetchall() + result = [] + for row in rows: + if isinstance(row, dict): + result.append({ + "id": row["employee_id"], + "code": row["employee_code"], + "name": row["name"], + "name_kana": row.get("name_kana", "") + }) + else: + result.append({ + "id": row[0], + "code": row[1], + "name": row[2], + "name_kana": row[3] if len(row) > 3 else "" + }) + return {"employees": result, "total": len(result)} + except Exception as e: + logger.error(f"従業員リスト取得エラー: {e}") + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/app/payroll/withholding_slip/schemas.py b/backend/app/payroll/withholding_slip/schemas.py new file mode 100644 index 0000000..e7b79cf --- /dev/null +++ b/backend/app/payroll/withholding_slip/schemas.py @@ -0,0 +1,95 @@ +""" +給与所得の源泉徴収票 スキーマ定義 +""" +from pydantic import BaseModel +from typing import Optional, List +from decimal import Decimal + + +class DependentInfo(BaseModel): + """扶養親族情報""" + name: str + name_kana: Optional[str] = None + relationship: Optional[str] = None + birth_date: Optional[str] = None + is_spouse: bool = False + is_disabled: bool = False + income_amount: Decimal = Decimal("0") + my_number: Optional[str] = None + deduction_type: Optional[str] = None # 一般/特定/老人(同居)/老人(その他) + deduction_amount: Decimal = Decimal("0") + + +class WithholdingSlipData(BaseModel): + """源泉徴収票データ""" + # 従業員情報 + employee_id: int + employee_code: str + employee_name: str + employee_name_kana: Optional[str] = None + employee_address: Optional[str] = None + employee_birth_date: Optional[str] = None + my_number: Optional[str] = None + + # 対象年 + tax_year: int + + # 支払金額 + total_payment: Decimal = Decimal("0") # 年間総支給額(通勤手当含む) + total_payment_excl_commute: Decimal = Decimal("0") # 通勤手当を除く支払金額(源泉徴収票記載用) + + # 給与所得控除後の金額 + kyuyo_shotoku: Decimal = Decimal("0") + + # 社会保険料等の金額 + social_insurance_total: Decimal = Decimal("0") + health_insurance_total: Decimal = Decimal("0") + care_insurance_total: Decimal = Decimal("0") + pension_total: Decimal = Decimal("0") + employment_insurance_total: Decimal = Decimal("0") + + # 基礎控除 + basic_deduction: Decimal = Decimal("0") + + # 配偶者控除 + has_spouse_deduction: bool = False + spouse_deduction_amount: Decimal = Decimal("0") + spouse_name: Optional[str] = None + spouse_name_kana: Optional[str] = None + spouse_income: Decimal = Decimal("0") + spouse_deduction_type: Optional[str] = None # 配偶者控除/配偶者特別控除 + + # 扶養親族 + dependents_over16: List[DependentInfo] = [] # 16歳以上控除対象扶養親族 + dependents_under16: List[DependentInfo] = [] # 16歳未満扶養親族 + dependent_deduction_total: Decimal = Decimal("0") + + # 所得控除の額の合計額 + total_deduction_amount: Decimal = Decimal("0") + + # 源泉徴収税額 + income_tax_withheld: Decimal = Decimal("0") + + # 月数内訳 + months_with_salary: int = 0 + months_with_bonus: int = 0 + + # 摘要 + remarks: Optional[str] = None + + +class WithholdingSlipRequest(BaseModel): + """源泉徴収票生成リクエスト""" + tax_year: int + employee_ids: List[int] + company_name: Optional[str] = None + company_address: Optional[str] = None + company_phone: Optional[str] = None + company_my_number: Optional[str] = None + + +class WithholdingSlipResponse(BaseModel): + """源泉徴収票レスポンス""" + tax_year: int + slips: List[WithholdingSlipData] + total_count: int diff --git a/backend/app/payroll/withholding_slip/service.py b/backend/app/payroll/withholding_slip/service.py new file mode 100644 index 0000000..3c77c6c --- /dev/null +++ b/backend/app/payroll/withholding_slip/service.py @@ -0,0 +1,388 @@ +""" +給与所得の源泉徴収票 計算サービス +""" +from decimal import Decimal, ROUND_DOWN +from datetime import date +from typing import List, Optional, Dict, Any +import logging + +from app.core.database import get_connection +from .schemas import WithholdingSlipData, DependentInfo + +logger = logging.getLogger(__name__) + +# 通勤手当の非課税限度額(月額150,000円) +COMMUTE_TAX_FREE_MONTHLY = Decimal("150000") + + +def calc_kyuyo_shotoku(annual_income: Decimal) -> Decimal: + """ + 給与所得控除後の金額を計算する(年末調整用) + 給与所得控除額を差し引いた後、1円未満切り捨て + """ + income = int(annual_income) + if income <= 550_000: + # 給与所得は0 + return Decimal("0") + elif income <= 1_625_000: + kojo = 550_000 + elif income <= 1_800_000: + # income * 40% - 100,000 だが1円単位で計算 + kojo = int(income * 0.4) - 100_000 + elif income <= 3_600_000: + kojo = int(income * 0.3) + 80_000 + elif income <= 6_600_000: + kojo = int(income * 0.2) + 440_000 + elif income <= 8_500_000: + kojo = int(income * 0.1) + 1_100_000 + else: + kojo = 1_950_000 + + shotoku = max(0, income - kojo) + return Decimal(str(shotoku)) + + +def calc_basic_deduction(tax_year: int) -> Decimal: + """ + 基礎控除額を計算する + 令和7年(2025年)以降: 580,000円 + 令和2年(2020年)〜令和6年(2024年): 480,000円 + """ + if tax_year >= 2025: + return Decimal("580000") + elif tax_year >= 2020: + return Decimal("480000") + else: + return Decimal("380000") + + +def get_age_at_year_end(birth_date: date, tax_year: int) -> int: + """その年の12月31日時点の年齢を計算""" + year_end = date(tax_year, 12, 31) + age = year_end.year - birth_date.year + if (year_end.month, year_end.day) < (birth_date.month, birth_date.day): + age -= 1 + return age + + +def calc_dependent_deduction(dep: Dict[str, Any], tax_year: int) -> tuple: + """ + 扶養控除額を計算する + Returns: (deduction_type, deduction_amount) + """ + birth_date = dep.get("birth_date") + if birth_date is None: + return ("一般", Decimal("380000")) + + age = get_age_at_year_end(birth_date, tax_year) + + if age >= 70: + # 老人扶養親族 (同居老親等かどうかは不明なのでその他として扱う) + return ("老人扶養", Decimal("480000")) + elif 19 <= age <= 22: + # 特定扶養親族 + return ("特定扶養", Decimal("630000")) + elif age >= 16: + # 一般扶養親族 + return ("一般扶養", Decimal("380000")) + else: + # 16歳未満 - 控除なし + return ("年少", Decimal("0")) + + +def calc_spouse_deduction(spouse_income: Decimal, employee_income: Decimal, spouse_age: Optional[int]) -> tuple: + """ + 配偶者控除・配偶者特別控除を計算する + Returns: (deduction_type, deduction_amount) + """ + spouse_inc = int(spouse_income) + emp_inc = int(employee_income) + + # 納税者の合計所得金額が1,000万円超の場合は配偶者控除なし + # 給与所得として概算 (ここでは支払金額から控除後を算出) + emp_shotoku = int(calc_kyuyo_shotoku(employee_income)) + if emp_shotoku > 10_000_000: + return (None, Decimal("0")) + + # 配偶者の合計所得が48万円(収入103万円相当)以下 → 配偶者控除 + if spouse_inc <= 480_000: + if spouse_age and spouse_age >= 70: + return ("老人配偶者控除", Decimal("480000")) + return ("配偶者控除", Decimal("380000")) + + # 配偶者特別控除(配偶者の合計所得 48万1円〜133万円) + elif spouse_inc <= 1_330_000: + # 段階的な配偶者特別控除 + if spouse_inc <= 950_000: + return ("配偶者特別控除", Decimal("380000")) + elif spouse_inc <= 1_000_000: + return ("配偶者特別控除", Decimal("360000")) + elif spouse_inc <= 1_050_000: + return ("配偶者特別控除", Decimal("310000")) + elif spouse_inc <= 1_100_000: + return ("配偶者特別控除", Decimal("260000")) + elif spouse_inc <= 1_150_000: + return ("配偶者特別控除", Decimal("210000")) + elif spouse_inc <= 1_200_000: + return ("配偶者特別控除", Decimal("160000")) + elif spouse_inc <= 1_250_000: + return ("配偶者特別控除", Decimal("110000")) + elif spouse_inc <= 1_300_000: + return ("配偶者特別控除", Decimal("60000")) + else: + return ("配偶者特別控除", Decimal("30000")) + else: + return (None, Decimal("0")) + + +def get_withholding_slip_data(tax_year: int, employee_id: int) -> Optional[WithholdingSlipData]: + """ + 指定年度・従業員の源泉徴収票データを計算して返す + """ + with get_connection() as conn: + with conn.cursor() as cur: + # 1. 従業員情報を取得 + cur.execute( + """ + SELECT employee_id, employee_code, name, name_kana, + address, birth_date, my_number + FROM employees + WHERE employee_id = %s + """, + (employee_id,) + ) + emp = cur.fetchone() + if not emp: + return None + + # 2. 月次給与データを集計(その年のすべての月) + cur.execute( + """ + SELECT + COUNT(*) as month_count, + COALESCE(SUM(total_payment), 0) as total_payment, + COALESCE(SUM(commute_allowance), 0) as total_commute, + COALESCE(SUM(health_insurance), 0) as health_ins, + COALESCE(SUM(care_insurance), 0) as care_ins, + COALESCE(SUM(pension_insurance), 0) as pension_ins, + COALESCE(SUM(employment_insurance), 0) as emp_ins, + COALESCE(SUM(income_tax), 0) as income_tax + FROM monthly_payroll + WHERE employee_id = %s + AND payroll_year = %s + """, + (employee_id, tax_year) + ) + salary_summary = cur.fetchone() + + # 3. 賞与データを集計(その年のすべての賞与) + cur.execute( + """ + SELECT + COUNT(*) as bonus_count, + COALESCE(SUM(total_bonus), 0) as total_bonus, + COALESCE(SUM(health_insurance), 0) as health_ins, + COALESCE(SUM(care_insurance), 0) as care_ins, + COALESCE(SUM(pension_insurance), 0) as pension_ins, + COALESCE(SUM(employment_insurance), 0) as emp_ins, + COALESCE(SUM(income_tax), 0) as income_tax + FROM bonus_payments + WHERE employee_id = %s + AND bonus_year = %s + """, + (employee_id, tax_year) + ) + bonus_summary = cur.fetchone() + + # 4. 扶養親族情報を取得(その年の12月31日時点で有効なもの) + cur.execute( + """ + SELECT d.dependent_id, d.name, d.relationship, d.birth_date, + d.is_spouse, d.is_disabled, d.income_amount, d.mynumber + FROM dependents d + WHERE d.employee_id = %s + AND d.valid_from <= %s + AND (d.valid_to IS NULL OR d.valid_to >= %s) + ORDER BY d.is_spouse DESC, d.birth_date + """, + (employee_id, + date(tax_year, 12, 31), + date(tax_year, 12, 31)) + ) + dep_rows = cur.fetchall() + + # 5. 集計データを整理 + s = salary_summary or {} + b = bonus_summary or {} + + def to_dec(v) -> Decimal: + if v is None: + return Decimal("0") + return Decimal(str(v)) + + salary_total = to_dec(s.get("total_payment", 0)) + salary_commute = to_dec(s.get("total_commute", 0)) + bonus_total = to_dec(b.get("total_bonus", 0)) + + # 通勤手当の非課税額(月150,000円上限)は支払金額から除外 + # 簡易計算:通勤手当全額を非課税として除外(厳密には上限チェックが必要) + commute_taxable = Decimal("0") # 通勤手当は非課税として全額除外 + + # 源泉徴収票の「支払金額」= 給与+賞与の総支給額(通勤手当を除く) + # 実務上は通勤手当を除いた金額を記載することが多い + total_pay_for_slip = salary_total - salary_commute + bonus_total + + # 社会保険料合計 + health_ins = to_dec(s.get("health_ins", 0)) + to_dec(b.get("health_ins", 0)) + care_ins = to_dec(s.get("care_ins", 0)) + to_dec(b.get("care_ins", 0)) + pension_ins = to_dec(s.get("pension_ins", 0)) + to_dec(b.get("pension_ins", 0)) + emp_ins = to_dec(s.get("emp_ins", 0)) + to_dec(b.get("emp_ins", 0)) + social_ins_total = health_ins + care_ins + pension_ins + emp_ins + + # 源泉徴収税額合計 + income_tax_total = to_dec(s.get("income_tax", 0)) + to_dec(b.get("income_tax", 0)) + + # 6. 給与所得控除後の金額を計算 + kyuyo_shotoku = calc_kyuyo_shotoku(total_pay_for_slip) + + # 7. 基礎控除 + basic_deduction = calc_basic_deduction(tax_year) + + # 8. 扶養親族の処理 + birth_date_emp = emp.get("birth_date") if isinstance(emp, dict) else None + + dependents_over16 = [] + dependents_under16 = [] + dependent_deduction_total = Decimal("0") + spouse_info = None + spouse_deduction_amount = Decimal("0") + has_spouse_deduction = False + spouse_deduction_type = None + + for dep in dep_rows: + if isinstance(dep, dict): + d = dep + else: + d = { + "dependent_id": dep[0], "name": dep[1], "relationship": dep[2], + "birth_date": dep[3], "is_spouse": dep[4], "is_disabled": dep[5], + "income_amount": dep[6], "mynumber": dep[7] + } + + dep_birth = d.get("birth_date") + dep_age = get_age_at_year_end(dep_birth, tax_year) if dep_birth else None + dep_income = to_dec(d.get("income_amount", 0)) + + if d.get("is_spouse"): + # 配偶者処理 + spouse_age = dep_age + sp_type, sp_amount = calc_spouse_deduction(dep_income, total_pay_for_slip, spouse_age) + if sp_type: + has_spouse_deduction = True + spouse_deduction_amount = sp_amount + spouse_deduction_type = sp_type + spouse_deduction_total = sp_amount + else: + spouse_deduction_total = Decimal("0") + + spouse_info = DependentInfo( + name=d.get("name", ""), + relationship="配偶者", + birth_date=str(dep_birth) if dep_birth else None, + is_spouse=True, + is_disabled=bool(d.get("is_disabled", False)), + income_amount=dep_income, + my_number=d.get("mynumber"), + deduction_type=sp_type, + deduction_amount=spouse_deduction_amount + ) + else: + # 扶養親族 + dep_type, dep_amount = calc_dependent_deduction(d, tax_year) + dep_obj = DependentInfo( + name=d.get("name", ""), + relationship=d.get("relationship", ""), + birth_date=str(dep_birth) if dep_birth else None, + is_spouse=False, + is_disabled=bool(d.get("is_disabled", False)), + income_amount=dep_income, + my_number=d.get("mynumber"), + deduction_type=dep_type, + deduction_amount=dep_amount + ) + + if dep_age is not None and dep_age < 16: + dependents_under16.append(dep_obj) + else: + dependents_over16.append(dep_obj) + dependent_deduction_total += dep_amount + + # 9. 所得控除の額の合計額 + total_deduction_amount = ( + social_ins_total + + basic_deduction + + spouse_deduction_amount + + dependent_deduction_total + ) + + # 10. 摘要(社会保険料の内訳) + remarks_parts = [] + if health_ins > 0: + remarks_parts.append(f"健康保険料{int(health_ins):,}円") + if care_ins > 0: + remarks_parts.append(f"介護保険料{int(care_ins):,}円") + if pension_ins > 0: + remarks_parts.append(f"厚生年金{int(pension_ins):,}円") + if emp_ins > 0: + remarks_parts.append(f"雇用保険{int(emp_ins):,}円") + remarks = " ".join(remarks_parts) if remarks_parts else None + + months_with_salary = int(s.get("month_count", 0)) if s else 0 + months_with_bonus = int(b.get("bonus_count", 0)) if b else 0 + + return WithholdingSlipData( + employee_id=emp.get("employee_id") if isinstance(emp, dict) else emp[0], + employee_code=emp.get("employee_code", "") if isinstance(emp, dict) else emp[1], + employee_name=emp.get("name", "") if isinstance(emp, dict) else emp[2], + employee_name_kana=emp.get("name_kana") if isinstance(emp, dict) else emp[3], + employee_address=emp.get("address") if isinstance(emp, dict) else emp[4], + employee_birth_date=str(emp.get("birth_date")) if (isinstance(emp, dict) and emp.get("birth_date")) else None, + my_number=emp.get("my_number") if isinstance(emp, dict) else emp[6], + tax_year=tax_year, + total_payment=salary_total + bonus_total, + total_payment_excl_commute=total_pay_for_slip, + kyuyo_shotoku=kyuyo_shotoku, + social_insurance_total=social_ins_total, + health_insurance_total=health_ins, + care_insurance_total=care_ins, + pension_total=pension_ins, + employment_insurance_total=emp_ins, + basic_deduction=basic_deduction, + has_spouse_deduction=has_spouse_deduction, + spouse_deduction_amount=spouse_deduction_amount, + spouse_name=spouse_info.name if spouse_info else None, + spouse_name_kana=spouse_info.name_kana if spouse_info else None, + spouse_income=spouse_info.income_amount if spouse_info else Decimal("0"), + spouse_deduction_type=spouse_deduction_type, + dependents_over16=dependents_over16, + dependents_under16=dependents_under16, + dependent_deduction_total=dependent_deduction_total, + total_deduction_amount=total_deduction_amount, + income_tax_withheld=income_tax_total, + months_with_salary=months_with_salary, + months_with_bonus=months_with_bonus, + remarks=remarks, + ) + + +def get_withholding_slips(tax_year: int, employee_ids: List[int]) -> List[WithholdingSlipData]: + """複数従業員の源泉徴収票データを取得""" + results = [] + for eid in employee_ids: + try: + slip = get_withholding_slip_data(tax_year, eid) + if slip: + results.append(slip) + except Exception as e: + logger.error(f"源泉徴収票計算エラー employee_id={eid}: {e}") + return results diff --git a/frontend/payroll.html b/frontend/payroll.html index 9ac50b9..20094b9 100644 --- a/frontend/payroll.html +++ b/frontend/payroll.html @@ -110,6 +110,15 @@

税率・保険料設定

社会保険料率、所得税率表の管理を行います

+ +
月次給与計算で勤怠情報を入力し、給与を計算・承認します +
  • + 源泉徴収票で年末に給与所得の源泉徴収票(本人・区役所・税務署の3部)を発行します +
  • diff --git a/frontend/withholding-slip.html b/frontend/withholding-slip.html new file mode 100644 index 0000000..02dacd4 --- /dev/null +++ b/frontend/withholding-slip.html @@ -0,0 +1,586 @@ + + + + + + + + + + 給与所得の源泉徴収票 + + + + +
    + +

    給与所得の源泉徴収票

    + + +
    +

    ① 発行条件の設定

    +
    +
    + + +
    +
    + + +
    +
    +
    + 読み込み中... +
    + +
    +

    ② 支払者情報(源泉徴収票下部に印刷)

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    + + +
    +
    + +
    +
    + + +
    + + + +