給与管理システム新規作成
This commit is contained in:
3
backend/app/payroll/__init__.py
Normal file
3
backend/app/payroll/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
給与管理モジュール (Payroll Module)
|
||||
"""
|
||||
1
backend/app/payroll/bonus/__init__.py
Normal file
1
backend/app/payroll/bonus/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Bonus Module
|
||||
139
backend/app/payroll/bonus/router.py
Normal file
139
backend/app/payroll/bonus/router.py
Normal file
@@ -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()
|
||||
63
backend/app/payroll/bonus/schemas.py
Normal file
63
backend/app/payroll/bonus/schemas.py
Normal file
@@ -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
|
||||
235
backend/app/payroll/bonus/service.py
Normal file
235
backend/app/payroll/bonus/service.py
Normal file
@@ -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
|
||||
}
|
||||
3
backend/app/payroll/calculation/__init__.py
Normal file
3
backend/app/payroll/calculation/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
月次給与計算 (Monthly Payroll Calculation)
|
||||
"""
|
||||
284
backend/app/payroll/calculation/router.py
Normal file
284
backend/app/payroll/calculation/router.py
Normal file
@@ -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()
|
||||
129
backend/app/payroll/calculation/schemas.py
Normal file
129
backend/app/payroll/calculation/schemas.py
Normal file
@@ -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
|
||||
317
backend/app/payroll/calculation/service.py
Normal file
317
backend/app/payroll/calculation/service.py
Normal file
@@ -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
|
||||
}
|
||||
3
backend/app/payroll/employees/__init__.py
Normal file
3
backend/app/payroll/employees/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
従業員管理 (Employee Management)
|
||||
"""
|
||||
196
backend/app/payroll/employees/router.py
Normal file
196
backend/app/payroll/employees/router.py
Normal file
@@ -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()
|
||||
109
backend/app/payroll/employees/schemas.py
Normal file
109
backend/app/payroll/employees/schemas.py
Normal file
@@ -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] = []
|
||||
3
backend/app/payroll/settings/__init__.py
Normal file
3
backend/app/payroll/settings/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
給与設定・税率・保険料管理 (Settings Management)
|
||||
"""
|
||||
869
backend/app/payroll/settings/router.py
Normal file
869
backend/app/payroll/settings/router.py
Normal file
@@ -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()
|
||||
|
||||
220
backend/app/payroll/settings/schemas.py
Normal file
220
backend/app/payroll/settings/schemas.py
Normal file
@@ -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
|
||||
Reference in New Issue
Block a user