diff --git a/backend/add_tax_year_field.py b/backend/add_tax_year_field.py new file mode 100644 index 0000000..7cbf8f2 --- /dev/null +++ b/backend/add_tax_year_field.py @@ -0,0 +1,43 @@ +import os +import psycopg2 +from dotenv import load_dotenv + +load_dotenv() + +conn = psycopg2.connect( + host=os.getenv('DB_HOST'), + port=os.getenv('DB_PORT'), + dbname=os.getenv('DB_NAME'), + user=os.getenv('DB_USER'), + password=os.getenv('DB_PASSWORD') +) + +cur = conn.cursor() + +# 年度フィールドを追加 +try: + cur.execute("ALTER TABLE income_tax_table ADD COLUMN tax_year INTEGER") + print("✓ tax_year カラムを追加") +except Exception as e: + print(f"カラム追加スキップ(既に存在する可能性): {e}") + conn.rollback() + +# 既存データに年度を設定 +try: + cur.execute("UPDATE income_tax_table SET tax_year = EXTRACT(YEAR FROM valid_from) WHERE tax_year IS NULL") + print("✓ 既存データに年度を設定") +except Exception as e: + print(f"エラー: {e}") + +# インデックスを追加 +try: + cur.execute("CREATE INDEX IF NOT EXISTS idx_income_tax_year ON income_tax_table(tax_year)") + print("✓ インデックスを追加") +except Exception as e: + print(f"インデックス追加エラー: {e}") + +conn.commit() +cur.close() +conn.close() + +print("\n完了!") diff --git a/backend/app/main.py b/backend/app/main.py index 553727d..1616c85 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -81,14 +81,40 @@ app.include_router(year_locks.router) from app.routers import cash app.include_router(cash.router) +# ───────────────────────── +# 給与モジュール (Payroll Module) +# ───────────────────────── +from app.payroll.employees.router import router as payroll_employees_router +app.include_router(payroll_employees_router) + +from app.payroll.settings.router import router as payroll_settings_router +app.include_router(payroll_settings_router) + +from app.payroll.calculation.router import router as payroll_calculation_router +app.include_router(payroll_calculation_router) + +from app.payroll.bonus.router import router as payroll_bonus_router +app.include_router(payroll_bonus_router) + from fastapi.staticfiles import StaticFiles +import os -app.mount( - "/static", - StaticFiles(directory="app/static", html=True), - name="static", -) +# 静的ファイル配信(コメントアウト - ディレクトリが存在しない) +# app.mount( +# "/static", +# StaticFiles(directory="app/static", html=True), +# name="static", +# ) + +# フロントエンドファイル配信(コメントアウト - file://プロトコルで直接アクセスするため不要) +# frontend_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend") +# if os.path.exists(frontend_path): +# app.mount( +# "/", +# StaticFiles(directory=frontend_path, html=True), +# name="frontend", +# ) diff --git a/backend/app/payroll/__init__.py b/backend/app/payroll/__init__.py new file mode 100644 index 0000000..d80ee17 --- /dev/null +++ b/backend/app/payroll/__init__.py @@ -0,0 +1,3 @@ +""" +給与管理モジュール (Payroll Module) +""" diff --git a/backend/app/payroll/bonus/__init__.py b/backend/app/payroll/bonus/__init__.py new file mode 100644 index 0000000..50b9161 --- /dev/null +++ b/backend/app/payroll/bonus/__init__.py @@ -0,0 +1 @@ +# Bonus Module diff --git a/backend/app/payroll/bonus/router.py b/backend/app/payroll/bonus/router.py new file mode 100644 index 0000000..4f1446d --- /dev/null +++ b/backend/app/payroll/bonus/router.py @@ -0,0 +1,139 @@ +""" +賞与計算API (Bonus Calculation API) +""" +from fastapi import APIRouter, HTTPException, status +from typing import List +from datetime import datetime +from . import schemas +from .service import BonusCalculationService +from ...core.database import get_connection + + +router = APIRouter(prefix="/payroll/bonus", tags=["Payroll - Bonus"]) + + +@router.post("/calculate", response_model=schemas.BonusPayment, status_code=status.HTTP_201_CREATED) +def calculate_bonus(request: schemas.BonusCalculationRequest): + """賞与を計算""" + # 賞与を計算 + calc_result = BonusCalculationService.calculate_bonus( + employee_id=request.employee_id, + bonus_year=request.bonus_year, + bonus_month=request.bonus_month, + bonus_type=request.bonus_type, + payment_date=request.payment_date, + base_bonus=request.base_bonus, + performance_bonus=request.performance_bonus, + other_bonus=request.other_bonus, + other_deduction=request.other_deduction, + calculated_by=request.calculated_by + ) + + # データベースに保存 + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO bonus_payments ( + employee_id, bonus_year, bonus_month, bonus_type, payment_date, status, + base_bonus, performance_bonus, other_bonus, total_bonus, + health_insurance, care_insurance, pension_insurance, employment_insurance, + income_tax, other_deduction, total_deduction, + net_bonus, calculated_at, calculated_by + ) VALUES ( + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, + %s, %s, %s + ) + RETURNING * + """, + ( + calc_result["employee_id"], calc_result["bonus_year"], calc_result["bonus_month"], + calc_result["bonus_type"], calc_result["payment_date"], calc_result["status"], + calc_result["base_bonus"], calc_result["performance_bonus"], + calc_result["other_bonus"], calc_result["total_bonus"], + calc_result["health_insurance"], calc_result["care_insurance"], + calc_result["pension_insurance"], calc_result["employment_insurance"], + calc_result["income_tax"], calc_result["other_deduction"], + calc_result["total_deduction"], + calc_result["net_bonus"], datetime.now(), calc_result["calculated_by"] + ) + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/", response_model=List[schemas.BonusPayment]) +def get_bonuses(bonus_year: int = None, employee_id: int = None): + """賞与一覧を取得""" + conditions = [] + params = [] + + if bonus_year: + conditions.append("bonus_year = %s") + params.append(bonus_year) + + if employee_id: + conditions.append("employee_id = %s") + params.append(employee_id) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT * FROM bonus_payments + WHERE {where_clause} + ORDER BY bonus_year DESC, bonus_month DESC + """, + params + ) + return cur.fetchall() + + +@router.get("/{bonus_id}", response_model=schemas.BonusPayment) +def get_bonus(bonus_id: int): + """賞与詳細を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT * FROM bonus_payments WHERE bonus_id = %s", (bonus_id,)) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="賞与が見つかりません") + return result + + +@router.post("/{bonus_id}/approve", response_model=schemas.BonusPayment) +def approve_bonus(bonus_id: int, approved_by: str): + """賞与を承認""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE bonus_payments + SET status = 'approved', approved_at = %s, approved_by = %s + WHERE bonus_id = %s AND status = 'calculated' + RETURNING * + """, + (datetime.now(), approved_by, bonus_id) + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="賞与が見つかりません、または既に承認済みです") + conn.commit() + return result + + +@router.delete("/{bonus_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_bonus(bonus_id: int): + """賞与を削除""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM bonus_payments WHERE bonus_id = %s AND status = 'draft'", (bonus_id,)) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="賞与が見つかりません、または削除できません") + conn.commit() diff --git a/backend/app/payroll/bonus/schemas.py b/backend/app/payroll/bonus/schemas.py new file mode 100644 index 0000000..ff7e4b8 --- /dev/null +++ b/backend/app/payroll/bonus/schemas.py @@ -0,0 +1,63 @@ +""" +賞与計算のスキーマ定義 (Bonus Schemas) +""" +from pydantic import BaseModel +from datetime import date, datetime +from typing import Optional +from decimal import Decimal + + +class BonusPaymentBase(BaseModel): + """賞与計算基本情報""" + employee_id: int + bonus_year: int + bonus_month: int + bonus_type: str # 夏季賞与/冬季賞与/決算賞与/その他 + payment_date: date + base_bonus: Decimal = Decimal("0") + performance_bonus: Decimal = Decimal("0") + other_bonus: Decimal = Decimal("0") + + +class BonusPaymentCreate(BonusPaymentBase): + """賞与計算の作成""" + pass + + +class BonusCalculationRequest(BaseModel): + """賞与計算リクエスト""" + employee_id: int + bonus_year: int + bonus_month: int + bonus_type: str + payment_date: date + base_bonus: Decimal + performance_bonus: Decimal = Decimal("0") + other_bonus: Decimal = Decimal("0") + other_deduction: Decimal = Decimal("0") + calculated_by: str = "system" + + +class BonusPayment(BonusPaymentBase): + """賞与計算結果""" + bonus_id: int + status: str + total_bonus: Decimal + health_insurance: Decimal + care_insurance: Decimal + pension_insurance: Decimal + employment_insurance: Decimal + income_tax: Decimal + other_deduction: Decimal + total_deduction: Decimal + net_bonus: Decimal + notes: Optional[str] = None + calculated_at: Optional[datetime] = None + calculated_by: Optional[str] = None + approved_at: Optional[datetime] = None + approved_by: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True diff --git a/backend/app/payroll/bonus/service.py b/backend/app/payroll/bonus/service.py new file mode 100644 index 0000000..198c59d --- /dev/null +++ b/backend/app/payroll/bonus/service.py @@ -0,0 +1,235 @@ +""" +賞与計算サービス (Bonus Calculation Service) +""" +from decimal import Decimal +from datetime import date +from typing import Dict, Any +from ...core.database import get_connection + + +class BonusCalculationService: + """賞与計算サービス""" + + @staticmethod + def get_insurance_rate(rate_type: str, target_date: date) -> Dict[str, Any]: + """保険料率を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM insurance_rates + WHERE rate_type = %s + AND valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY valid_from DESC + LIMIT 1 + """, + (rate_type, target_date, target_date) + ) + return cur.fetchone() + + @staticmethod + def get_insurance_limit(setting_type: str, target_date: date) -> Decimal: + """社会保険料上限を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT limit_amount FROM insurance_limit_settings + WHERE setting_type = %s + AND valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY valid_from DESC + LIMIT 1 + """, + (setting_type, target_date, target_date) + ) + result = cur.fetchone() + return Decimal(str(result["limit_amount"])) if result else Decimal("0") + + @staticmethod + def get_previous_salary_avg(employee_id: int, payment_date: date) -> Decimal: + """前月までの3ヶ月平均給与を取得(賞与所得税計算用)""" + with get_connection() as conn: + with conn.cursor() as cur: + # 前月までの3ヶ月の総支給額を取得 + cur.execute( + """ + SELECT AVG(total_payment - commute_allowance) as avg_salary + FROM monthly_payroll + WHERE employee_id = %s + AND payment_date < %s + AND status IN ('approved', 'paid') + ORDER BY payment_date DESC + LIMIT 3 + """, + (employee_id, payment_date) + ) + result = cur.fetchone() + return Decimal(str(result["avg_salary"])) if result and result["avg_salary"] else Decimal("0") + + @staticmethod + def calculate_bonus_income_tax( + bonus_amount: Decimal, + previous_salary_avg: Decimal, + dependents_count: int + ) -> Decimal: + """ + 賞与所得税を計算 + 賞与税率 = (前月給与額 - 社会保険料) × 扶養人数に応じた税率 + """ + # 簡易計算: 賞与額から社会保険料概算を引いた額の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")) + + @staticmethod + def calculate_bonus( + employee_id: int, + bonus_year: int, + bonus_month: int, + bonus_type: str, + payment_date: date, + base_bonus: Decimal, + performance_bonus: Decimal = Decimal("0"), + other_bonus: Decimal = Decimal("0"), + other_deduction: Decimal = Decimal("0"), + calculated_by: str = "system" + ) -> Dict[str, Any]: + """賞与を計算""" + + # 総支給額 + total_bonus = base_bonus + performance_bonus + other_bonus + + # 社会保険料上限を取得 + health_insurance_limit = BonusCalculationService.get_insurance_limit( + "健康保険標準報酬月額上限", payment_date + ) + pension_insurance_limit = BonusCalculationService.get_insurance_limit( + "厚生年金標準報酬月額上限", payment_date + ) + + # 賞与の標準賞与額(上限適用) + health_standard_bonus = min(total_bonus, health_insurance_limit) if health_insurance_limit > 0 else total_bonus + pension_standard_bonus = min(total_bonus, pension_insurance_limit) if pension_insurance_limit > 0 else total_bonus + + # 従業員情報を取得(年齢計算のため) + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT birth_date FROM employees WHERE employee_id = %s", + (employee_id,) + ) + employee_info = cur.fetchone() + + # 年齢を計算 + age = 0 + if employee_info and employee_info["birth_date"]: + age = (payment_date - employee_info["birth_date"]).days // 365 + + # 健康保険(賞与額の1/1000で計算) + health_insurance_rate = BonusCalculationService.get_insurance_rate("健康保険", payment_date) + health_insurance = Decimal("0") + if health_insurance_rate: + health_insurance = ( + health_standard_bonus * Decimal(str(health_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 介護保険(40歳以上65歳未満が対象) + care_insurance = Decimal("0") + if 40 <= age < 65: + care_insurance_rate = BonusCalculationService.get_insurance_rate("介護保険", payment_date) + if care_insurance_rate: + care_insurance = ( + health_standard_bonus * Decimal(str(care_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 厚生年金 + pension_insurance_rate = BonusCalculationService.get_insurance_rate("厚生年金", payment_date) + pension_insurance = Decimal("0") + if pension_insurance_rate: + pension_insurance = ( + pension_standard_bonus * Decimal(str(pension_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 雇用保険 + employment_insurance_rate = BonusCalculationService.get_insurance_rate("雇用保険", payment_date) + employment_insurance = Decimal("0") + if employment_insurance_rate: + employment_insurance = ( + total_bonus * Decimal(str(employment_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 所得税の計算(賞与特有の計算) + from ..calculation.service import PayrollCalculationService + dependents_count = PayrollCalculationService.get_active_dependents_count(employee_id, payment_date) + previous_salary_avg = BonusCalculationService.get_previous_salary_avg(employee_id, payment_date) + + # 課税対象額 + taxable_bonus = ( + total_bonus - + health_insurance - + care_insurance - + pension_insurance - + employment_insurance + ) + + income_tax = BonusCalculationService.calculate_bonus_income_tax( + taxable_bonus, + previous_salary_avg, + dependents_count + ) + + # 総控除額 + total_deduction = ( + health_insurance + + care_insurance + + pension_insurance + + employment_insurance + + income_tax + + other_deduction + ) + + # 差引支給額 + net_bonus = total_bonus - total_deduction + + return { + "employee_id": employee_id, + "bonus_year": bonus_year, + "bonus_month": bonus_month, + "bonus_type": bonus_type, + "payment_date": payment_date, + "status": "calculated", + + # 支給項目 + "base_bonus": base_bonus, + "performance_bonus": performance_bonus, + "other_bonus": other_bonus, + "total_bonus": total_bonus, + + # 控除項目 + "health_insurance": health_insurance, + "care_insurance": care_insurance, + "pension_insurance": pension_insurance, + "employment_insurance": employment_insurance, + "income_tax": income_tax, + "other_deduction": other_deduction, + "total_deduction": total_deduction, + + # 差引支給額 + "net_bonus": net_bonus, + + "calculated_by": calculated_by + } diff --git a/backend/app/payroll/calculation/__init__.py b/backend/app/payroll/calculation/__init__.py new file mode 100644 index 0000000..084a561 --- /dev/null +++ b/backend/app/payroll/calculation/__init__.py @@ -0,0 +1,3 @@ +""" +月次給与計算 (Monthly Payroll Calculation) +""" diff --git a/backend/app/payroll/calculation/router.py b/backend/app/payroll/calculation/router.py new file mode 100644 index 0000000..fedc8e8 --- /dev/null +++ b/backend/app/payroll/calculation/router.py @@ -0,0 +1,284 @@ +""" +月次給与計算API (Monthly Payroll Calculation API) +""" +from fastapi import APIRouter, HTTPException, status +from typing import List +from datetime import datetime +from . import schemas +from .service import PayrollCalculationService +from ...core.database import get_connection + + +router = APIRouter(prefix="/payroll/calculation", tags=["Payroll - Calculation"]) + + +@router.post("/calculate", response_model=schemas.MonthlyPayroll, status_code=status.HTTP_201_CREATED) +def calculate_payroll(request: schemas.PayrollCalculationRequest): + """給与を計算して保存""" + + # 既存のデータを確認 + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT payroll_id FROM monthly_payroll + WHERE employee_id = %s + AND payroll_year = %s + AND payroll_month = %s + """, + (request.employee_id, request.payroll_year, request.payroll_month) + ) + existing = cur.fetchone() + + if existing: + raise HTTPException( + status_code=400, + detail="この従業員の指定月の給与データは既に存在します" + ) + + # 給与計算を実行 + try: + calc_result = PayrollCalculationService.calculate_payroll( + employee_id=request.employee_id, + payroll_year=request.payroll_year, + payroll_month=request.payroll_month, + working_days=request.working_days, + working_hours=request.working_hours, + overtime_hours=request.overtime_hours, + holiday_hours=request.holiday_hours, + late_hours=request.late_hours, + absent_days=request.absent_days, + payment_date=request.payment_date, + other_allowance=request.other_allowance, + resident_tax=request.resident_tax, + other_deduction=request.other_deduction + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # 結果をデータベースに保存 + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO monthly_payroll ( + employee_id, payroll_year, payroll_month, payment_date, status, + working_days, working_hours, overtime_hours, holiday_hours, late_hours, absent_days, + base_salary, overtime_pay, holiday_pay, commute_allowance, other_allowance, total_payment, + health_insurance, care_insurance, pension_insurance, employment_insurance, + income_tax, resident_tax, other_deduction, total_deduction, + net_payment, calculated_at, calculated_by + ) VALUES ( + %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s + ) + RETURNING * + """, + ( + calc_result["employee_id"], calc_result["payroll_year"], calc_result["payroll_month"], + calc_result["payment_date"], calc_result["status"], + calc_result["working_days"], calc_result["working_hours"], calc_result["overtime_hours"], + calc_result["holiday_hours"], calc_result["late_hours"], calc_result["absent_days"], + calc_result["base_salary"], calc_result["overtime_pay"], calc_result["holiday_pay"], + calc_result["commute_allowance"], calc_result["other_allowance"], calc_result["total_payment"], + calc_result["health_insurance"], calc_result["care_insurance"], + calc_result["pension_insurance"], calc_result["employment_insurance"], + calc_result["income_tax"], calc_result["resident_tax"], calc_result["other_deduction"], + calc_result["total_deduction"], + calc_result["net_payment"], datetime.now(), calc_result["calculated_by"] + ) + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/", response_model=List[schemas.MonthlyPayroll]) +def get_payrolls(payroll_year: int = None, payroll_month: int = None, employee_id: int = None): + """給与計算一覧を取得""" + conditions = [] + params = [] + + if payroll_year: + conditions.append("payroll_year = %s") + params.append(payroll_year) + + if payroll_month: + conditions.append("payroll_month = %s") + params.append(payroll_month) + + if employee_id: + conditions.append("employee_id = %s") + params.append(employee_id) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f""" + SELECT * FROM monthly_payroll + WHERE {where_clause} + ORDER BY payroll_year DESC, payroll_month DESC, employee_id + """, + params + ) + return cur.fetchall() + + +@router.get("/{payroll_id}", response_model=schemas.MonthlyPayrollWithDetails) +def get_payroll(payroll_id: int): + """給与計算詳細を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("SELECT * FROM monthly_payroll WHERE payroll_id = %s", (payroll_id,)) + payroll = cur.fetchone() + + if not payroll: + raise HTTPException(status_code=404, detail="給与データが見つかりません") + + cur.execute( + "SELECT * FROM payroll_details WHERE payroll_id = %s ORDER BY detail_id", + (payroll_id,) + ) + details = cur.fetchall() + + return {**payroll, "details": details} + + +@router.put("/{payroll_id}", response_model=schemas.MonthlyPayroll) +def update_payroll(payroll_id: int, request: schemas.MonthlyPayrollUpdate): + """給与データを更新(再計算は行わない)""" + update_data = request.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) + values = list(update_data.values()) + [payroll_id] + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE monthly_payroll SET {set_clause} WHERE payroll_id = %s RETURNING *", + values, + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="給与データが見つかりません") + conn.commit() + return result + + +@router.post("/{payroll_id}/recalculate", response_model=schemas.MonthlyPayroll) +def recalculate_payroll(payroll_id: int): + """給与を再計算""" + with get_connection() as conn: + with conn.cursor() as cur: + # 既存データを取得 + cur.execute("SELECT * FROM monthly_payroll WHERE payroll_id = %s", (payroll_id,)) + existing = cur.fetchone() + + if not existing: + raise HTTPException(status_code=404, detail="給与データが見つかりません") + + if existing["status"] == "approved": + raise HTTPException(status_code=400, detail="承認済みの給与は再計算できません") + + # 再計算 + try: + calc_result = PayrollCalculationService.calculate_payroll( + employee_id=existing["employee_id"], + payroll_year=existing["payroll_year"], + payroll_month=existing["payroll_month"], + working_days=existing["working_days"], + working_hours=existing["working_hours"], + overtime_hours=existing["overtime_hours"], + holiday_hours=existing["holiday_hours"], + late_hours=existing["late_hours"], + absent_days=existing["absent_days"], + payment_date=existing["payment_date"], + other_allowance=existing["other_allowance"], + resident_tax=existing["resident_tax"], + other_deduction=existing["other_deduction"], + calculated_by="recalculated" + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # 更新 + cur.execute( + """ + 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, + income_tax = %s, total_deduction = %s, net_payment = %s, + calculated_at = %s, calculated_by = %s + WHERE payroll_id = %s + RETURNING * + """, + ( + 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["income_tax"], calc_result["total_deduction"], calc_result["net_payment"], + datetime.now(), calc_result["calculated_by"], + payroll_id + ) + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.post("/{payroll_id}/approve", response_model=schemas.MonthlyPayroll) +def approve_payroll(payroll_id: int, request: schemas.PayrollApprovalRequest): + """給与を承認""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + UPDATE monthly_payroll + SET status = 'approved', approved_at = %s, approved_by = %s + WHERE payroll_id = %s AND status = 'calculated' + RETURNING * + """, + (datetime.now(), request.approved_by, payroll_id) + ) + result = cur.fetchone() + + if not result: + raise HTTPException( + status_code=400, + detail="給与データが見つからないか、既に承認されています" + ) + + conn.commit() + return result + + +@router.delete("/{payroll_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_payroll(payroll_id: int): + """給与データを削除""" + with get_connection() as conn: + with conn.cursor() as cur: + # ステータス確認 + cur.execute( + "SELECT status FROM monthly_payroll WHERE payroll_id = %s", + (payroll_id,) + ) + payroll = cur.fetchone() + + if not payroll: + raise HTTPException(status_code=404, detail="給与データが見つかりません") + + if payroll["status"] == "approved": + raise HTTPException(status_code=400, detail="承認済みの給与は削除できません") + + cur.execute("DELETE FROM monthly_payroll WHERE payroll_id = %s", (payroll_id,)) + conn.commit() diff --git a/backend/app/payroll/calculation/schemas.py b/backend/app/payroll/calculation/schemas.py new file mode 100644 index 0000000..00e4ec9 --- /dev/null +++ b/backend/app/payroll/calculation/schemas.py @@ -0,0 +1,129 @@ +""" +月次給与計算のスキーマ定義 (Payroll Calculation Schemas) +""" +from pydantic import BaseModel +from datetime import date, datetime +from typing import Optional +from decimal import Decimal + + +class PayrollDetailBase(BaseModel): + """給与明細項目""" + item_type: str # payment/deduction + item_code: str + item_name: str + amount: Decimal + notes: Optional[str] = None + + +class PayrollDetailCreate(PayrollDetailBase): + """給与明細項目の作成""" + pass + + +class PayrollDetail(PayrollDetailBase): + """給与明細項目の情報""" + detail_id: int + payroll_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class MonthlyPayrollBase(BaseModel): + """月次給与計算の基本情報""" + employee_id: int + payroll_year: int + payroll_month: int + payment_date: date + + # 勤怠情報 + working_days: Decimal = Decimal("0") + working_hours: Decimal = Decimal("0") + overtime_hours: Decimal = Decimal("0") + holiday_hours: Decimal = Decimal("0") + late_hours: Decimal = Decimal("0") + absent_days: Decimal = Decimal("0") + + notes: Optional[str] = None + + +class MonthlyPayrollCreate(MonthlyPayrollBase): + """月次給与計算の作成""" + pass + + +class MonthlyPayrollUpdate(BaseModel): + """月次給与計算の更新""" + payment_date: Optional[date] = None + working_days: Optional[Decimal] = None + working_hours: Optional[Decimal] = None + overtime_hours: Optional[Decimal] = None + holiday_hours: Optional[Decimal] = None + late_hours: Optional[Decimal] = None + absent_days: Optional[Decimal] = None + notes: Optional[str] = None + + +class MonthlyPayroll(MonthlyPayrollBase): + """月次給与計算の情報""" + payroll_id: int + status: str + + # 支給項目 + base_salary: Decimal + overtime_pay: Decimal + holiday_pay: Decimal + commute_allowance: Decimal + other_allowance: Decimal + total_payment: Decimal + + # 控除項目 + health_insurance: Decimal + pension_insurance: Decimal + employment_insurance: Decimal + income_tax: Decimal + resident_tax: Decimal + other_deduction: Decimal + total_deduction: Decimal + + # 差引支給額 + net_payment: Decimal + + calculated_at: Optional[datetime] = None + calculated_by: Optional[str] = None + approved_at: Optional[datetime] = None + approved_by: Optional[str] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class MonthlyPayrollWithDetails(MonthlyPayroll): + """明細項目を含む月次給与情報""" + details: list[PayrollDetail] = [] + + +class PayrollCalculationRequest(BaseModel): + """給与計算リクエスト""" + employee_id: int + payroll_year: int + payroll_month: int + working_days: Decimal = Decimal("0") + working_hours: Decimal = Decimal("0") + overtime_hours: Decimal = Decimal("0") + holiday_hours: Decimal = Decimal("0") + late_hours: Decimal = Decimal("0") + absent_days: Decimal = Decimal("0") + payment_date: date + other_allowance: Decimal = Decimal("0") + resident_tax: Decimal = Decimal("0") + other_deduction: Decimal = Decimal("0") + + +class PayrollApprovalRequest(BaseModel): + """給与承認リクエスト""" + approved_by: str diff --git a/backend/app/payroll/calculation/service.py b/backend/app/payroll/calculation/service.py new file mode 100644 index 0000000..d9128b0 --- /dev/null +++ b/backend/app/payroll/calculation/service.py @@ -0,0 +1,317 @@ +""" +給与計算サービス (Payroll Calculation Service) +""" +from decimal import Decimal +from datetime import date +from typing import Dict, Any +from ...core.database import get_connection + + +class PayrollCalculationService: + """給与計算サービス""" + + @staticmethod + def get_active_dependents_count(employee_id: int, target_date: date) -> int: + """有効な扶養家族数を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT COUNT(*) as count FROM dependents + WHERE employee_id = %s + AND valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + """, + (employee_id, target_date, target_date) + ) + result = cur.fetchone() + return result["count"] if result else 0 + + @staticmethod + def get_salary_setting(employee_id: int, target_date: date) -> Dict[str, Any]: + """給与設定を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM salary_settings + WHERE employee_id = %s + AND valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY valid_from DESC + LIMIT 1 + """, + (employee_id, target_date, target_date) + ) + return cur.fetchone() + + @staticmethod + def get_insurance_rate(rate_type: str, target_date: date) -> Dict[str, Any]: + """保険料率を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM insurance_rates + WHERE rate_type = %s + AND valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY valid_from DESC + LIMIT 1 + """, + (rate_type, target_date, target_date) + ) + return cur.fetchone() + + @staticmethod + def get_insurance_limit(setting_type: str, target_date: date) -> Decimal: + """社会保険料上限を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT limit_amount FROM insurance_limit_settings + WHERE setting_type = %s + AND valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY valid_from DESC + LIMIT 1 + """, + (setting_type, target_date, target_date) + ) + result = cur.fetchone() + return Decimal(str(result["limit_amount"])) if result else Decimal("0") + + @staticmethod + def calculate_income_tax( + monthly_income: Decimal, + dependents_count: int, + target_date: date + ) -> Decimal: + """所得税を計算""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT tax_amount FROM income_tax_table + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + AND dependents_count = %s + AND monthly_income_from <= %s + AND monthly_income_to >= %s + ORDER BY valid_from DESC + LIMIT 1 + """, + (target_date, target_date, dependents_count, monthly_income, monthly_income) + ) + result = cur.fetchone() + return Decimal(str(result["tax_amount"])) if result else Decimal("0") + + @staticmethod + def calculate_payroll( + employee_id: int, + payroll_year: int, + payroll_month: int, + working_days: Decimal, + working_hours: Decimal, + overtime_hours: Decimal, + holiday_hours: Decimal, + late_hours: Decimal, + absent_days: Decimal, + payment_date: date, + other_allowance: Decimal = Decimal("0"), + resident_tax: Decimal = Decimal("0"), + other_deduction: Decimal = Decimal("0"), + calculated_by: str = "system" + ) -> Dict[str, Any]: + """給与を計算""" + + # 給与設定を取得 + salary_setting = PayrollCalculationService.get_salary_setting(employee_id, payment_date) + if not salary_setting: + raise ValueError("給与設定が見つかりません") + + # 基本給の計算 + base_salary = Decimal(str(salary_setting["base_salary"])) + + # 時給の場合は勤務時間から計算 + if salary_setting["payment_type"] == "時給" and salary_setting["hourly_rate"]: + hourly_rate = Decimal(str(salary_setting["hourly_rate"])) + base_salary = hourly_rate * working_hours + + # 欠勤控除(日給制の場合) + absence_deduction = Decimal("0") + if absent_days > 0 and salary_setting["payment_type"] == "月給": + # 月給を営業日数で割って欠勤日数を掛ける(簡易計算) + daily_rate = base_salary / Decimal("22") # 月平均営業日数を22日と仮定 + absence_deduction = daily_rate * absent_days + base_salary = base_salary - absence_deduction + + # 残業手当の計算(時給 × 1.25) + overtime_pay = Decimal("0") + if overtime_hours > 0: + if salary_setting["hourly_rate"]: + hourly_rate = Decimal(str(salary_setting["hourly_rate"])) + else: + # 月給を時給に換算(月平均労働時間を160時間と仮定) + hourly_rate = Decimal(str(salary_setting["base_salary"])) / Decimal("160") + overtime_pay = hourly_rate * overtime_hours * Decimal("1.25") + + # 休日手当の計算(時給 × 1.35) + holiday_pay = Decimal("0") + if holiday_hours > 0: + if salary_setting["hourly_rate"]: + hourly_rate = Decimal(str(salary_setting["hourly_rate"])) + else: + hourly_rate = Decimal(str(salary_setting["base_salary"])) / Decimal("160") + holiday_pay = hourly_rate * holiday_hours * Decimal("1.35") + + # 通勤手当 + commute_allowance = Decimal(str(salary_setting["commute_allowance"])) + + # 総支給額 + total_payment = ( + base_salary + overtime_pay + holiday_pay + + commute_allowance + other_allowance + ) + + # 社会保険料の計算(標準報酬月額を総支給額と仮定) + standard_monthly_salary = total_payment + + # 社会保険料上限を取得 + health_insurance_limit = PayrollCalculationService.get_insurance_limit( + "健康保険標準報酬月額上限", payment_date + ) + pension_insurance_limit = PayrollCalculationService.get_insurance_limit( + "厚生年金標準報酬月額上限", payment_date + ) + commute_tax_free_limit = PayrollCalculationService.get_insurance_limit( + "通勤手当非課税限度額", payment_date + ) + + # 標準報酬月額に上限を適用 + health_standard_salary = min(standard_monthly_salary, health_insurance_limit) if health_insurance_limit > 0 else standard_monthly_salary + pension_standard_salary = min(standard_monthly_salary, pension_insurance_limit) if pension_insurance_limit > 0 else standard_monthly_salary + + # 従業員情報を取得(年齢計算のため) + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT birth_date FROM employees WHERE employee_id = %s", + (employee_id,) + ) + employee_info = cur.fetchone() + + # 年齢を計算(介護保険は40歳以上が対象) + age = 0 + if employee_info and employee_info["birth_date"]: + age = (payment_date - employee_info["birth_date"]).days // 365 + + # 健康保険(上限適用後の標準報酬月額で計算) + health_insurance_rate = PayrollCalculationService.get_insurance_rate("健康保険", payment_date) + health_insurance = Decimal("0") + if health_insurance_rate: + health_insurance = ( + health_standard_salary * Decimal(str(health_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 介護保険(40歳以上65歳未満が対象、健康保険と同じ標準報酬月額) + care_insurance = Decimal("0") + if 40 <= age < 65: + care_insurance_rate = PayrollCalculationService.get_insurance_rate("介護保険", payment_date) + if care_insurance_rate: + care_insurance = ( + health_standard_salary * Decimal(str(care_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 厚生年金(上限適用後の標準報酬月額で計算) + pension_insurance_rate = PayrollCalculationService.get_insurance_rate("厚生年金", payment_date) + pension_insurance = Decimal("0") + if pension_insurance_rate: + pension_insurance = ( + pension_standard_salary * Decimal(str(pension_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 雇用保険 + employment_insurance_rate = PayrollCalculationService.get_insurance_rate("雇用保険", payment_date) + employment_insurance = Decimal("0") + if employment_insurance_rate: + employment_insurance = ( + total_payment * Decimal(str(employment_insurance_rate["employee_rate"])) + ).quantize(Decimal("1")) + + # 所得税の計算 + dependents_count = PayrollCalculationService.get_active_dependents_count(employee_id, payment_date) + + # 通勤手当非課税限度額を取得(設定がなければ150000円) + if commute_tax_free_limit == Decimal("0"): + commute_tax_free_limit = Decimal("150000") + + # 課税対象額 = 総支給額 - 通勤手当(非課税限度額まで) - 社会保険料 + taxable_income = ( + total_payment - + min(commute_allowance, commute_tax_free_limit) - + health_insurance - + care_insurance - + pension_insurance - + employment_insurance + ) + + income_tax = PayrollCalculationService.calculate_income_tax( + taxable_income, + dependents_count, + payment_date + ) + + # 総控除額 + total_deduction = ( + health_insurance + + care_insurance + + pension_insurance + + employment_insurance + + income_tax + + resident_tax + + other_deduction + ) + + # 差引支給額 + net_payment = total_payment - total_deduction + + return { + "employee_id": employee_id, + "payroll_year": payroll_year, + "payroll_month": payroll_month, + "payment_date": payment_date, + "status": "calculated", + + # 勤怠情報 + "working_days": working_days, + "working_hours": working_hours, + "overtime_hours": overtime_hours, + "holiday_hours": holiday_hours, + "late_hours": late_hours, + "absent_days": absent_days, + + # 支給項目 + "base_salary": base_salary, + "overtime_pay": overtime_pay, + "holiday_pay": holiday_pay, + "commute_allowance": commute_allowance, + "other_allowance": other_allowance, + "total_payment": total_payment, + + # 控除項目 + "health_insurance": health_insurance, + "care_insurance": care_insurance, + "pension_insurance": pension_insurance, + "employment_insurance": employment_insurance, + "income_tax": income_tax, + "resident_tax": resident_tax, + "other_deduction": other_deduction, + "total_deduction": total_deduction, + + # 差引支給額 + "net_payment": net_payment, + + "calculated_by": calculated_by + } diff --git a/backend/app/payroll/employees/__init__.py b/backend/app/payroll/employees/__init__.py new file mode 100644 index 0000000..a4950de --- /dev/null +++ b/backend/app/payroll/employees/__init__.py @@ -0,0 +1,3 @@ +""" +従業員管理 (Employee Management) +""" diff --git a/backend/app/payroll/employees/router.py b/backend/app/payroll/employees/router.py new file mode 100644 index 0000000..f2a3fe1 --- /dev/null +++ b/backend/app/payroll/employees/router.py @@ -0,0 +1,196 @@ +""" +従業員管理API (Employee Management API) +""" +from fastapi import APIRouter, HTTPException, status +from typing import List +from . import schemas +from ...core.database import get_connection + + +router = APIRouter(prefix="/payroll/employees", tags=["Payroll - Employees"]) + + +@router.post("/", response_model=schemas.Employee, status_code=status.HTTP_201_CREATED) +def create_employee(employee: schemas.EmployeeCreate): + """従業員を作成""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO employees ( + employee_code, name, name_kana, birth_date, gender, + email, phone, postal_code, address, hire_date, termination_date, is_active, + bank_name, bank_branch, bank_account_number, bank_account_type, notes + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + employee.employee_code, employee.name, employee.name_kana, + employee.birth_date, employee.gender, + employee.email, employee.phone, employee.postal_code, employee.address, + employee.hire_date, employee.termination_date, employee.is_active, + employee.bank_name, employee.bank_branch, + employee.bank_account_number, employee.bank_account_type, employee.notes + ), + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/", response_model=List[schemas.Employee]) +def get_employees(is_active: bool = None): + """従業員一覧を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + if is_active is not None: + cur.execute( + "SELECT * FROM employees WHERE is_active = %s ORDER BY employee_code", + (is_active,) + ) + else: + cur.execute("SELECT * FROM employees ORDER BY employee_code") + return cur.fetchall() + + +@router.get("/{employee_id}", response_model=schemas.EmployeeWithDependents) +def get_employee(employee_id: int): + """従業員の詳細を取得(扶養家族を含む)""" + with get_connection() as conn: + with conn.cursor() as cur: + # 従業員情報取得 + cur.execute("SELECT * FROM employees WHERE employee_id = %s", (employee_id,)) + employee = cur.fetchone() + if not employee: + raise HTTPException(status_code=404, detail="従業員が見つかりません") + + # 扶養家族情報取得 + cur.execute( + """ + SELECT * FROM dependents + WHERE employee_id = %s + AND (valid_to IS NULL OR valid_to >= CURRENT_DATE) + ORDER BY valid_from + """, + (employee_id,) + ) + dependents = cur.fetchall() + + return {**employee, "dependents": dependents} + + +@router.put("/{employee_id}", response_model=schemas.Employee) +def update_employee(employee_id: int, employee: schemas.EmployeeUpdate): + """従業員情報を更新""" + update_data = employee.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) + values = list(update_data.values()) + [employee_id] + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE employees SET {set_clause} WHERE employee_id = %s RETURNING *", + values, + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="従業員が見つかりません") + conn.commit() + return result + + +@router.delete("/{employee_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_employee(employee_id: int): + """従業員を削除""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM employees WHERE employee_id = %s", (employee_id,)) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="従業員が見つかりません") + conn.commit() + + +# ======================================== +# 扶養家族管理 +# ======================================== + +@router.post("/{employee_id}/dependents", response_model=schemas.Dependent, status_code=status.HTTP_201_CREATED) +def create_dependent(employee_id: int, dependent: schemas.DependentCreate): + """扶養家族を追加""" + with get_connection() as conn: + with conn.cursor() as cur: + # 従業員の存在確認 + cur.execute("SELECT employee_id FROM employees WHERE employee_id = %s", (employee_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="従業員が見つかりません") + + cur.execute( + """ + INSERT INTO dependents ( + employee_id, name, relationship, birth_date, + is_spouse, is_disabled, income_amount, valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + employee_id, dependent.name, dependent.relationship, dependent.birth_date, + dependent.is_spouse, dependent.is_disabled, dependent.income_amount, + dependent.valid_from, dependent.valid_to + ), + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/{employee_id}/dependents", response_model=List[schemas.Dependent]) +def get_dependents(employee_id: int): + """従業員の扶養家族一覧を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM dependents + WHERE employee_id = %s + ORDER BY valid_from DESC + """, + (employee_id,) + ) + return cur.fetchall() + + +@router.put("/dependents/{dependent_id}", response_model=schemas.Dependent) +def update_dependent(dependent_id: int, dependent: schemas.DependentUpdate): + """扶養家族情報を更新""" + update_data = dependent.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) + values = list(update_data.values()) + [dependent_id] + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE dependents SET {set_clause} WHERE dependent_id = %s RETURNING *", + values, + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="扶養家族が見つかりません") + conn.commit() + return result + + +@router.delete("/dependents/{dependent_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_dependent(dependent_id: int): + """扶養家族を削除""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM dependents WHERE dependent_id = %s", (dependent_id,)) + if cur.rowcount == 0: + raise HTTPException(status_code=404, detail="扶養家族が見つかりません") + conn.commit() diff --git a/backend/app/payroll/employees/schemas.py b/backend/app/payroll/employees/schemas.py new file mode 100644 index 0000000..5edda0e --- /dev/null +++ b/backend/app/payroll/employees/schemas.py @@ -0,0 +1,109 @@ +""" +従業員管理用のスキーマ定義 (Employee Schemas) +""" +from pydantic import BaseModel, EmailStr +from datetime import date, datetime +from typing import Optional +from decimal import Decimal + + +class DependentBase(BaseModel): + """扶養家族の基本情報""" + name: str + relationship: str # 配偶者/子/親/その他 + birth_date: Optional[date] = None + is_spouse: bool = False + is_disabled: bool = False + income_amount: Decimal = Decimal("0") + valid_from: date + valid_to: Optional[date] = None + + +class DependentCreate(DependentBase): + """扶養家族の作成""" + pass + + +class DependentUpdate(BaseModel): + """扶養家族の更新""" + name: Optional[str] = None + relationship: Optional[str] = None + birth_date: Optional[date] = None + is_spouse: Optional[bool] = None + is_disabled: Optional[bool] = None + income_amount: Optional[Decimal] = None + valid_from: Optional[date] = None + valid_to: Optional[date] = None + + +class Dependent(DependentBase): + """扶養家族の情報""" + dependent_id: int + employee_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class EmployeeBase(BaseModel): + """従業員の基本情報""" + employee_code: str + name: str + name_kana: Optional[str] = None + birth_date: Optional[date] = None + gender: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + postal_code: Optional[str] = None + address: Optional[str] = None + hire_date: date + termination_date: Optional[date] = None + is_active: bool = True + bank_name: Optional[str] = None + bank_branch: Optional[str] = None + bank_account_number: Optional[str] = None + bank_account_type: Optional[str] = None # 普通/当座 + notes: Optional[str] = None + + +class EmployeeCreate(EmployeeBase): + """従業員の作成""" + pass + + +class EmployeeUpdate(BaseModel): + """従業員の更新""" + employee_code: Optional[str] = None + name: Optional[str] = None + name_kana: Optional[str] = None + birth_date: Optional[date] = None + gender: Optional[str] = None + email: Optional[EmailStr] = None + phone: Optional[str] = None + postal_code: Optional[str] = None + address: Optional[str] = None + hire_date: Optional[date] = None + termination_date: Optional[date] = None + is_active: Optional[bool] = None + bank_name: Optional[str] = None + bank_branch: Optional[str] = None + bank_account_number: Optional[str] = None + bank_account_type: Optional[str] = None + notes: Optional[str] = None + + +class Employee(EmployeeBase): + """従業員の情報""" + employee_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class EmployeeWithDependents(Employee): + """扶養家族を含む従業員情報""" + dependents: list[Dependent] = [] diff --git a/backend/app/payroll/settings/__init__.py b/backend/app/payroll/settings/__init__.py new file mode 100644 index 0000000..0bee403 --- /dev/null +++ b/backend/app/payroll/settings/__init__.py @@ -0,0 +1,3 @@ +""" +給与設定・税率・保険料管理 (Settings Management) +""" diff --git a/backend/app/payroll/settings/router.py b/backend/app/payroll/settings/router.py new file mode 100644 index 0000000..bbc8b82 --- /dev/null +++ b/backend/app/payroll/settings/router.py @@ -0,0 +1,869 @@ +""" +給与設定・税率・保険料管理API (Settings Management API) +""" +from fastapi import APIRouter, HTTPException, status, UploadFile, File +from typing import List +from datetime import date +import csv +import io +from decimal import Decimal +from pathlib import Path +import openpyxl +import xlrd +from . import schemas +from ...core.database import get_connection + + +router = APIRouter(prefix="/payroll/settings", tags=["Payroll - Settings"]) + + +# ======================================== +# 給与基本設定 +# ======================================== + +@router.post("/salary", response_model=schemas.SalarySetting, status_code=status.HTTP_201_CREATED) +def create_salary_setting(setting: schemas.SalarySettingCreate): + """給与設定を作成""" + with get_connection() as conn: + with conn.cursor() as cur: + # 従業員の存在確認 + cur.execute("SELECT employee_id FROM employees WHERE employee_id = %s", (setting.employee_id,)) + if not cur.fetchone(): + raise HTTPException(status_code=404, detail="従業員が見つかりません") + + cur.execute( + """ + INSERT INTO salary_settings ( + employee_id, base_salary, hourly_rate, employment_type, + payment_type, commute_allowance, other_allowance, + valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + setting.employee_id, setting.base_salary, setting.hourly_rate, + setting.employment_type, setting.payment_type, + setting.commute_allowance, setting.other_allowance, + setting.valid_from, setting.valid_to + ), + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/salary/{employee_id}", response_model=List[schemas.SalarySetting]) +def get_salary_settings(employee_id: int): + """従業員の給与設定履歴を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM salary_settings + WHERE employee_id = %s + ORDER BY valid_from DESC + """, + (employee_id,) + ) + return cur.fetchall() + + +@router.get("/salary/{employee_id}/current", response_model=schemas.SalarySetting) +def get_current_salary_setting(employee_id: int, target_date: date = None): + """従業員の現在の給与設定を取得""" + if target_date is None: + target_date = date.today() + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM salary_settings + WHERE employee_id = %s + AND valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY valid_from DESC + LIMIT 1 + """, + (employee_id, target_date, target_date) + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="給与設定が見つかりません") + return result + + +@router.put("/salary/{setting_id}", response_model=schemas.SalarySetting) +def update_salary_setting(setting_id: int, setting: schemas.SalarySettingUpdate): + """給与設定を更新""" + update_data = setting.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) + values = list(update_data.values()) + [setting_id] + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE salary_settings SET {set_clause} WHERE setting_id = %s RETURNING *", + values, + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="給与設定が見つかりません") + conn.commit() + return result + + +# ======================================== +# 社会保険料率管理 +# ======================================== + +@router.post("/insurance-rates", response_model=schemas.InsuranceRate, status_code=status.HTTP_201_CREATED) +def create_insurance_rate(rate: schemas.InsuranceRateCreate): + """保険料率を作成""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO insurance_rates ( + rate_year, rate_type, calculation_target, prefecture, + employee_rate, employer_rate, care_insurance_age_threshold, notes + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + rate.rate_year, rate.rate_type, rate.calculation_target, rate.prefecture, + rate.employee_rate, rate.employer_rate, rate.care_insurance_age_threshold, rate.notes + ), + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/insurance-rates", response_model=List[schemas.InsuranceRate]) +def get_insurance_rates( + rate_year: int = None, + calculation_target: str = None, + prefecture: str = None +): + """保険料率一覧を取得(年度・計算対象・都道府県でフィルター可能)""" + with get_connection() as conn: + with conn.cursor() as cur: + conditions = [] + params = [] + + if rate_year: + conditions.append("rate_year = %s") + params.append(rate_year) + if calculation_target: + conditions.append("calculation_target = %s") + params.append(calculation_target) + if prefecture: + conditions.append("prefecture = %s") + params.append(prefecture) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + cur.execute( + f""" + SELECT * FROM insurance_rates + WHERE {where_clause} + ORDER BY rate_year DESC, calculation_target, rate_type + """, + params + ) + return cur.fetchall() + + +@router.delete("/insurance-rates/{rate_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_insurance_rate(rate_id: int): + """保険料率を削除""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM insurance_rates WHERE rate_id = %s RETURNING rate_id", (rate_id,)) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="保険料率が見つかりません") + conn.commit() + + +@router.put("/insurance-rates/{rate_id}", response_model=schemas.InsuranceRate) +def update_insurance_rate(rate_id: int, rate: schemas.InsuranceRateUpdate): + """保険料率を更新""" + print(f"\n=== 保険料率更新リクエスト ===") + print(f"Rate ID: {rate_id}") + print(f"受信データ: {rate}") + + update_data = rate.model_dump(exclude_unset=True) + print(f"更新データ: {update_data}") + + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) + values = list(update_data.values()) + [rate_id] + + print(f"SQL SET句: {set_clause}") + print(f"SQL値: {values}") + + with get_connection() as conn: + with conn.cursor() as cur: + sql = f"UPDATE insurance_rates SET {set_clause} WHERE rate_id = %s RETURNING *" + print(f"実行SQL: {sql}") + + cur.execute(sql, values) + result = cur.fetchone() + + if not result: + raise HTTPException(status_code=404, detail="保険料率が見つかりません") + + print(f"更新結果: {result}") + conn.commit() + print("コミット完了") + return result + + +# ======================================== +# 所得税率表管理 +# ======================================== + +@router.post("/income-tax", response_model=schemas.IncomeTaxTable, status_code=status.HTTP_201_CREATED) +def create_income_tax_table(tax: schemas.IncomeTaxTableCreate): + """所得税率表を作成""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO income_tax_table ( + monthly_income_from, monthly_income_to, dependents_count, + tax_amount, valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + tax.monthly_income_from, tax.monthly_income_to, + tax.dependents_count, tax.tax_amount, + tax.valid_from, tax.valid_to + ), + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.post("/income-tax/import") +def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None): + """ + 所得税率表をCSV/Excelからインポート + 税年度が指定されない場合、Excelファイルのタイトルから自動抽出(例:令和5年分 → 2023) + 同じ年度の既存データは自動的に削除されます + """ + imported_count = 0 + filename = file.filename.lower() + extracted_year = tax_year + + with get_connection() as conn: + with conn.cursor() as cur: + # Excel file処理 + if filename.endswith(('.xlsx', '.xls')): + content = file.file.read() + + # .xls (旧形式) と .xlsx (新形式) で処理を分岐 + if filename.endswith('.xls') and not filename.endswith('.xlsx'): + # .xls形式 - xlrdを使用 + workbook = xlrd.open_workbook(file_contents=content) + sheet = workbook.sheet_by_index(0) + + # 年度を自動抽出(まずB1セルを優先的にチェック) + if not extracted_year: + import re + # B1セル(0行1列)を最優先でチェック + if sheet.nrows > 0 and sheet.ncols > 1: + b1_value = sheet.cell_value(0, 1) + if b1_value and isinstance(b1_value, str): + print(f"B1セルの値: {b1_value}") + if '令和' in b1_value and '年' in b1_value: + match = re.search(r'令和(\d+)年', b1_value) + if match: + reiwa_year = int(match.group(1)) + extracted_year = 2018 + reiwa_year + print(f"B1セルから年度を抽出: 令和{reiwa_year}年 = {extracted_year}") + elif '平成' in b1_value and '年' in b1_value: + match = re.search(r'平成(\d+)年', b1_value) + if match: + heisei_year = int(match.group(1)) + extracted_year = 1988 + heisei_year + print(f"B1セルから年度を抽出: 平成{heisei_year}年 = {extracted_year}") + + # B1から取得できない場合は他のセルも検索 + if not extracted_year: + for row_idx in range(min(5, sheet.nrows)): + for col_idx in range(sheet.ncols): + cell_value = sheet.cell_value(row_idx, col_idx) + if cell_value and isinstance(cell_value, str): + if '令和' in cell_value and '年' in cell_value: + match = re.search(r'令和(\d+)年', cell_value) + if match: + reiwa_year = int(match.group(1)) + extracted_year = 2018 + reiwa_year + print(f"Excelから年度を抽出: 令和{reiwa_year}年 = {extracted_year}") + break + elif '平成' in cell_value and '年' in cell_value: + match = re.search(r'平成(\d+)年', cell_value) + if match: + heisei_year = int(match.group(1)) + extracted_year = 1988 + heisei_year + print(f"Excelから年度を抽出: 平成{heisei_year}年 = {extracted_year}") + break + if extracted_year: + break + + # 年度が取得できない場合は現在年度を使用 + if not extracted_year: + extracted_year = date.today().year + print(f"年度を自動設定: {extracted_year}") + + # 既存の同年度データを削除 + cur.execute("DELETE FROM income_tax_table WHERE tax_year = %s", (extracted_year,)) + deleted_count = cur.rowcount + print(f"{extracted_year}年度の既存データを削除: {deleted_count}件") + + valid_from = date(extracted_year, 1, 1) + valid_to = date(extracted_year, 12, 31) + + # ヘッダー行を探す(複数パターンに対応) + header_row = None + for row_idx in range(min(10, sheet.nrows)): + for col_idx in range(sheet.ncols): + cell_value = sheet.cell_value(row_idx, col_idx) + if cell_value and ( + '社会保険料控除後' in str(cell_value) or + '社会保' in str(cell_value) or + '給与等の金額' in str(cell_value) + ): + header_row = row_idx + print(f"ヘッダー行を検出: 行{row_idx}, セル値: {cell_value}") + break + if header_row is not None: + break + + if header_row is None: + # デバッグ用:最初の10行を出力 + print("デバッグ: 最初の10行を確認") + for row_idx in range(min(10, sheet.nrows)): + row_values = [str(sheet.cell_value(row_idx, c)) for c in range(min(5, sheet.ncols))] + print(f" 行{row_idx}: {row_values}") + raise HTTPException(status_code=400, detail="Excelのヘッダー行が見つかりません") + + # 扶養人数の列を特定 + dependents_cols = [] + header_row_idx = header_row + 2 + if header_row_idx < sheet.nrows: + for col_idx in range(sheet.ncols): + cell_value = sheet.cell_value(header_row_idx, col_idx) + if cell_value and '人' in str(cell_value): + try: + dep_count = int(str(cell_value).replace('人', '').strip()) + dependents_cols.append((col_idx, dep_count)) + print(f"扶養人数列を検出: 列{col_idx} = {dep_count}人") + except Exception as e: + print(f"扶養人数解析エラー: {cell_value}, エラー: {e}") + except: + pass + + # データ行を読み込む + data_start_row = header_row + 3 + for row_idx in range(data_start_row, sheet.nrows): + first_cell = sheet.cell_value(row_idx, 0) + if not first_cell: + continue + + try: + income_from = None + income_to = None + + for col_idx in range(min(3, sheet.ncols)): + cell_value = sheet.cell_value(row_idx, col_idx) + if cell_value: + val = str(cell_value).replace(',', '').replace('円', '').strip() + try: + if income_from is None: + income_from = Decimal(val) + elif income_to is None: + income_to = Decimal(val) + break + except: + pass + + if income_from is None: + continue + + # 扶養人数ごとの税額を取得 + for col_idx, dep_count in dependents_cols: + if col_idx < sheet.ncols: + tax_value = sheet.cell_value(row_idx, col_idx) + if tax_value not in (None, ''): + try: + tax_amount = Decimal(str(tax_value).replace(',', '').replace('円', '').strip()) + cur.execute( + """ + INSERT INTO income_tax_table ( + monthly_income_from, monthly_income_to, dependents_count, + tax_amount, tax_year, valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + (income_from, income_to, dep_count, tax_amount, extracted_year, valid_from, valid_to) + ) + imported_count += 1 + except Exception as e: + print(f"税額変換エラー: {tax_value}, エラー: {e}") + except Exception as e: + print(f"行処理エラー: {e}") + continue + + # .xlsファイルの処理完了 + conn.commit() + print(f"インポート完了: {imported_count}件のデータをインポートしました") + return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました"} + + else: + # .xlsx形式 - openpyxlを使用 + wb = openpyxl.load_workbook(io.BytesIO(content)) + ws = wb.active + + # 年度を自動抽出(まずB1セルを優先的にチェック) + if not extracted_year: + import re + # B1セルを最優先でチェック + b1_cell = ws['B1'] + if b1_cell.value: + b1_value = str(b1_cell.value) + print(f"B1セルの値: {b1_value}") + if '令和' in b1_value and '年' in b1_value: + match = re.search(r'令和(\d+)年', b1_value) + if match: + reiwa_year = int(match.group(1)) + extracted_year = 2018 + reiwa_year + print(f"B1セルから年度を抽出: 令和{reiwa_year}年 = {extracted_year}") + elif '平成' in b1_value and '年' in b1_value: + match = re.search(r'平成(\d+)年', b1_value) + if match: + heisei_year = int(match.group(1)) + extracted_year = 1988 + heisei_year + print(f"B1セルから年度を抽出: 平成{heisei_year}年 = {extracted_year}") + + # B1から取得できない場合は他のセルも検索 + if not extracted_year: + for row in ws.iter_rows(min_row=1, max_row=5): + for cell in row: + if cell.value: + cell_text = str(cell.value) + if '令和' in cell_text and '年' in cell_text: + match = re.search(r'令和(\d+)年', cell_text) + if match: + reiwa_year = int(match.group(1)) + extracted_year = 2018 + reiwa_year + print(f"Excelから年度を抽出: 令和{reiwa_year}年 = {extracted_year}") + break + elif '平成' in cell_text and '年' in cell_text: + match = re.search(r'平成(\d+)年', cell_text) + if match: + heisei_year = int(match.group(1)) + extracted_year = 1988 + heisei_year + print(f"Excelから年度を抽出: 平成{heisei_year}年 = {extracted_year}") + break + if extracted_year: + break + + # 年度が取得できない場合は現在年度を使用 + if not extracted_year: + extracted_year = date.today().year + print(f"年度を自動設定: {extracted_year}") + + # 既存の同年度データを削除 + cur.execute("DELETE FROM income_tax_table WHERE tax_year = %s", (extracted_year,)) + deleted_count = cur.rowcount + print(f"{extracted_year}年度の既存データを削除: {deleted_count}件") + + # valid_from と valid_to を設定 + valid_from = date(extracted_year, 1, 1) + valid_to = date(extracted_year, 12, 31) + + # ヘッダー行を探す + header_row = None + for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=10), start=1): + for cell in row: + if cell.value and '社会保険料控除後' in str(cell.value): + header_row = row_idx + break + if header_row: + break + + if not header_row: + raise HTTPException(status_code=400, detail="Excelのヘッダー行が見つかりません") + + # 扶養人数の列を特定 + dependents_cols = [] + header_row_data = list(ws.iter_rows(min_row=header_row+2, max_row=header_row+2))[0] + for col_idx, cell in enumerate(header_row_data, start=1): + if cell.value and '人' in str(cell.value): + try: + dep_count = int(str(cell.value).replace('人', '').strip()) + dependents_cols.append((col_idx, dep_count)) + except: + pass + + # データ行を読み込む + data_start_row = header_row + 3 + for row in ws.iter_rows(min_row=data_start_row): + if not row[0].value: + continue + + try: + income_from = None + income_to = None + + for i in range(min(3, len(row))): + if row[i].value: + val = str(row[i].value).replace(',', '').replace('円', '').strip() + try: + if income_from is None: + income_from = Decimal(val) + elif income_to is None: + income_to = Decimal(val) + break + except: + pass + + if income_from is None: + continue + + # 扶養人数ごとの税額を取得 + for col_idx, dep_count in dependents_cols: + tax_value = row[col_idx - 1].value if col_idx - 1 < len(row) else None + if tax_value is not None: + try: + tax_amount = Decimal(str(tax_value).replace(',', '').replace('円', '').strip()) + + cur.execute( + """ + INSERT INTO income_tax_table ( + monthly_income_from, monthly_income_to, dependents_count, + tax_amount, tax_year, valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + (income_from, income_to, dep_count, tax_amount, extracted_year, valid_from, valid_to) + ) + imported_count += 1 + except Exception as e: + print(f"税額変換エラー: {tax_value}, エラー: {e}") + pass + except Exception as e: + print(f"行処理エラー: {e}") + continue + + # .xlsxファイルの処理完了 + conn.commit() + print(f"インポート完了: {imported_count}件のデータをインポートしました") + return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました"} + + # CSV file処理 + else: + if not extracted_year: + extracted_year = date.today().year + + # 既存の同年度データを削除 + cur.execute("DELETE FROM income_tax_table WHERE tax_year = %s", (extracted_year,)) + deleted_count = cur.rowcount + print(f"{extracted_year}年度の既存データを削除: {deleted_count}件") + + valid_from = date(extracted_year, 1, 1) + valid_to = date(extracted_year, 12, 31) + + content = file.file.read().decode("utf-8-sig") + csv_reader = csv.DictReader(io.StringIO(content)) + + for row in csv_reader: + cur.execute( + """ + INSERT INTO income_tax_table ( + monthly_income_from, monthly_income_to, dependents_count, + tax_amount, tax_year, valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + ( + Decimal(row["月収From"]), + Decimal(row["月収To"]), + int(row["扶養人数"]), + Decimal(row["税額"]), + extracted_year, + valid_from, + valid_to + ), + ) + imported_count += 1 + + conn.commit() + + return {"message": f"{extracted_year}年度のデータを{imported_count}件インポートしました(既存データを置き換え)", "year": extracted_year} + + +@router.get("/income-tax", response_model=List[schemas.IncomeTaxTable]) +def get_income_tax_table(target_date: date = None, dependents_count: int = None): + """所得税率表を取得""" + if target_date is None: + target_date = date.today() + + with get_connection() as conn: + with conn.cursor() as cur: + if dependents_count is not None: + cur.execute( + """ + SELECT * FROM income_tax_table + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + AND dependents_count = %s + ORDER BY monthly_income_from + """, + (target_date, target_date, dependents_count) + ) + else: + cur.execute( + """ + SELECT * FROM income_tax_table + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY dependents_count, monthly_income_from + """, + (target_date, target_date) + ) + return cur.fetchall() + + +@router.get("/income-tax/calculate") +def calculate_income_tax(monthly_income: Decimal, dependents_count: int = 0, target_date: date = None): + """月収と扶養人数から所得税額を計算""" + if target_date is None: + target_date = date.today() + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT tax_amount FROM income_tax_table + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + AND dependents_count = %s + AND monthly_income_from <= %s + AND monthly_income_to >= %s + LIMIT 1 + """, + (target_date, target_date, dependents_count, monthly_income, monthly_income) + ) + result = cur.fetchone() + + if not result: + return {"monthly_income": monthly_income, "dependents_count": dependents_count, "tax_amount": 0} + + return { + "monthly_income": monthly_income, + "dependents_count": dependents_count, + "tax_amount": result["tax_amount"] + } + + +# ======================================== +# 社会保険料上限設定管理 +# ======================================== + +@router.post("/insurance-limits", response_model=schemas.InsuranceLimitSetting, status_code=status.HTTP_201_CREATED) +def create_insurance_limit(limit: schemas.InsuranceLimitSettingCreate): + """社会保険料上限設定を作成""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO insurance_limit_settings ( + setting_type, limit_amount, valid_from, valid_to, notes + ) VALUES (%s, %s, %s, %s, %s) + RETURNING * + """, + (limit.setting_type, limit.limit_amount, limit.valid_from, limit.valid_to, limit.notes) + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/insurance-limits", response_model=List[schemas.InsuranceLimitSetting]) +def get_insurance_limits(target_date: date = None): + """社会保険料上限設定一覧を取得""" + if target_date is None: + target_date = date.today() + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM insurance_limit_settings + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY setting_type, valid_from DESC + """, + (target_date, target_date) + ) + return cur.fetchall() + + +@router.delete("/insurance-limits/{limit_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_insurance_limit(limit_id: int): + """社会保険料上限設定を削除""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM insurance_limit_settings WHERE limit_id = %s RETURNING limit_id", (limit_id,)) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="上限設定が見つかりません") + conn.commit() + + +@router.put("/insurance-limits/{limit_id}", response_model=schemas.InsuranceLimitSetting) +def update_insurance_limit(limit_id: int, limit: schemas.InsuranceLimitSettingUpdate): + """社会保険料上限設定を更新""" + update_data = limit.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) + values = list(update_data.values()) + [limit_id] + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE insurance_limit_settings SET {set_clause} WHERE limit_id = %s RETURNING *", + values + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="上限設定が見つかりません") + conn.commit() + return result + + +# ======================================== +# 子ども・子育て拠出金率管理 +# ======================================== + +@router.post("/child-support-rates", response_model=schemas.ChildSupportContributionRate, status_code=status.HTTP_201_CREATED) +def create_child_support_rate(rate: schemas.ChildSupportContributionRateCreate): + """子ども・子育て拠出金率を作成""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO child_support_contribution_rates ( + rate_year, income_threshold, contribution_rate, notes + ) VALUES (%s, %s, %s, %s) + RETURNING * + """, + (rate.rate_year, rate.income_threshold, rate.contribution_rate, rate.notes) + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/child-support-rates", response_model=List[schemas.ChildSupportContributionRate]) +def get_child_support_rates(): + """子ども・子育て拠出金率一覧を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM child_support_contribution_rates + ORDER BY rate_year DESC + """ + ) + return cur.fetchall() + + +@router.delete("/child-support-rates/{contribution_id}", status_code=status.HTTP_204_NO_CONTENT) +def delete_child_support_rate(contribution_id: int): + """子ども・子育て拠出金率を削除""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM child_support_contribution_rates WHERE contribution_id = %s RETURNING contribution_id", (contribution_id,)) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="拠出金率が見つかりません") + conn.commit() + + +@router.put("/child-support-rates/{contribution_id}", response_model=schemas.ChildSupportContributionRate) +def update_child_support_rate(contribution_id: int, rate: schemas.ChildSupportContributionRateUpdate): + """子ども・子育て拠出金率を更新""" + update_data = rate.model_dump(exclude_unset=True) + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) + values = list(update_data.values()) + [contribution_id] + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + f"UPDATE child_support_contribution_rates SET {set_clause} WHERE contribution_id = %s RETURNING *", + values + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="拠出金率が見つかりません") + conn.commit() + return result + + +# ======================================== +# 扶養家族判定条件管理 +# ======================================== + +@router.post("/dependent-rules", response_model=schemas.DependentRule, status_code=status.HTTP_201_CREATED) +def create_dependent_rule(rule: schemas.DependentRuleCreate): + """扶養家族判定条件を作成""" + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + INSERT INTO dependent_rules ( + rule_type, rule_value, valid_from, valid_to, description + ) VALUES (%s, %s, %s, %s, %s) + RETURNING * + """, + (rule.rule_type, rule.rule_value, rule.valid_from, rule.valid_to, rule.description) + ) + result = cur.fetchone() + conn.commit() + return result + + +@router.get("/dependent-rules", response_model=List[schemas.DependentRule]) +def get_dependent_rules(target_date: date = None): + """扶養家族判定条件一覧を取得""" + if target_date is None: + target_date = date.today() + + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + """ + SELECT * FROM dependent_rules + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY rule_type, valid_from DESC + """, + (target_date, target_date) + ) + return cur.fetchall() + diff --git a/backend/app/payroll/settings/schemas.py b/backend/app/payroll/settings/schemas.py new file mode 100644 index 0000000..d94a83c --- /dev/null +++ b/backend/app/payroll/settings/schemas.py @@ -0,0 +1,220 @@ +""" +給与設定・税率・保険料のスキーマ定義 (Settings Schemas) +""" +from pydantic import BaseModel +from datetime import date, datetime +from typing import Optional +from decimal import Decimal + + +class SalarySettingBase(BaseModel): + """給与基本設定""" + employee_id: int + base_salary: Decimal = Decimal("0") + hourly_rate: Optional[Decimal] = None + employment_type: str # 正社員/契約社員/パート/アルバイト + payment_type: str # 月給/時給/日給 + commute_allowance: Decimal = Decimal("0") + other_allowance: Decimal = Decimal("0") + valid_from: date + valid_to: Optional[date] = None + + +class SalarySettingCreate(SalarySettingBase): + """給与設定の作成""" + pass + + +class SalarySettingUpdate(BaseModel): + """給与設定の更新""" + base_salary: Optional[Decimal] = None + hourly_rate: Optional[Decimal] = None + employment_type: Optional[str] = None + payment_type: Optional[str] = None + commute_allowance: Optional[Decimal] = None + other_allowance: Optional[Decimal] = None + valid_from: Optional[date] = None + valid_to: Optional[date] = None + + +class SalarySetting(SalarySettingBase): + """給与設定の情報""" + setting_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class InsuranceRateBase(BaseModel): + """社会保険料率""" + rate_year: int # 適用年度(例: 2026) + rate_type: str # 健康保険/介護保険/厚生年金/雇用保険/労災保険 + calculation_target: str # 給与/賞与 + prefecture: str = "東京都" # 事業所所在地 + employee_rate: Decimal + employer_rate: Decimal + care_insurance_age_threshold: Optional[int] = None # 介護保険適用年齢下限 + notes: Optional[str] = None + + +class InsuranceRateCreate(InsuranceRateBase): + """保険料率の作成""" + pass + + +class InsuranceRateUpdate(BaseModel): + """保険料率の更新""" + rate_year: Optional[int] = None + rate_type: Optional[str] = None + calculation_target: Optional[str] = None + prefecture: Optional[str] = None + employee_rate: Optional[Decimal] = None + employer_rate: Optional[Decimal] = None + care_insurance_age_threshold: Optional[int] = None + notes: Optional[str] = None + + +class InsuranceRate(InsuranceRateBase): + """保険料率の情報""" + rate_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class IncomeTaxTableBase(BaseModel): + """所得税率表""" + monthly_income_from: Decimal + monthly_income_to: Decimal + dependents_count: int = 0 + tax_amount: Decimal + valid_from: date + valid_to: Optional[date] = None + + +class IncomeTaxTableCreate(IncomeTaxTableBase): + """所得税率表の作成""" + pass + + +class IncomeTaxTable(IncomeTaxTableBase): + """所得税率表の情報""" + tax_id: int + created_at: datetime + + class Config: + from_attributes = True + + +# ======================================== +# 子ども・子育て拠出金率 +# ======================================== + +class ChildSupportContributionRateBase(BaseModel): + """子ども・子育て拠出金率""" + rate_year: int # 適用年度 + income_threshold: Decimal # 所得基準額(標準報酬月額) + contribution_rate: Decimal # 拠出金率(事業主負担のみ) + notes: Optional[str] = None + + +class ChildSupportContributionRateCreate(ChildSupportContributionRateBase): + """子ども・子育て拠出金率の作成""" + pass + + +class ChildSupportContributionRateUpdate(BaseModel): + """子ども・子育て拠出金率の更新""" + income_threshold: Optional[Decimal] = None + contribution_rate: Optional[Decimal] = None + notes: Optional[str] = None + + +class ChildSupportContributionRate(ChildSupportContributionRateBase): + """子ども・子育て拠出金率の情報""" + contribution_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# ======================================== +# 社会保険料上限設定 +# ======================================== + +class InsuranceLimitSettingBase(BaseModel): + """社会保険料上限設定""" + setting_type: str # 健康保険標準報酬月額上限/厚生年金標準報酬月額上限/通勤手当非課税限度額 + limit_amount: Decimal + valid_from: date + valid_to: Optional[date] = None + notes: Optional[str] = None + + +class InsuranceLimitSettingCreate(InsuranceLimitSettingBase): + """上限設定の作成""" + pass + + +class InsuranceLimitSettingUpdate(BaseModel): + """上限設定の更新""" + limit_amount: Optional[Decimal] = None + valid_from: Optional[date] = None + valid_to: Optional[date] = None + notes: Optional[str] = None + + +class InsuranceLimitSetting(InsuranceLimitSettingBase): + """上限設定の情報""" + limit_id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# ======================================== +# 扶養家族判定条件 +# ======================================== + +class DependentRuleBase(BaseModel): + """扶養家族判定条件""" + rule_type: str # 所得限度額/年齢条件/同一生計 + rule_value: str + valid_from: date + valid_to: Optional[date] = None + description: Optional[str] = None + + +class DependentRuleCreate(DependentRuleBase): + """判定条件の作成""" + pass + + +class DependentRule(DependentRuleBase): + """判定条件の情報""" + rule_id: int + created_at: datetime + + class Config: + from_attributes = True + + """所得税率表の情報""" + tax_id: int + created_at: datetime + + class Config: + from_attributes = True + + +class IncomeTaxImport(BaseModel): + """所得税率表のインポート用""" + data: list[IncomeTaxTableCreate] + valid_from: date diff --git a/backend/app/static/payroll-calculation.html b/backend/app/static/payroll-calculation.html new file mode 100644 index 0000000..4619a0f --- /dev/null +++ b/backend/app/static/payroll-calculation.html @@ -0,0 +1,434 @@ + + +
+ + +従業員の給与計算、扶養管理、保険料・税金の管理を行います
+ + + +従業員の給与計算、扶養管理、保険料・税金の管理を行います
+ + + +