給与管理システム新規作成

This commit is contained in:
admin
2026-01-20 11:15:49 +09:00
parent 9dc7cbcb07
commit 8a00de8f03
45 changed files with 8925 additions and 5 deletions

View File

@@ -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",
# )

View File

@@ -0,0 +1,3 @@
"""
給与管理モジュール (Payroll Module)
"""

View File

@@ -0,0 +1 @@
# Bonus Module

View 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()

View 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

View 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
}

View File

@@ -0,0 +1,3 @@
"""
月次給与計算 (Monthly Payroll Calculation)
"""

View 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()

View 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

View 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
}

View File

@@ -0,0 +1,3 @@
"""
従業員管理 (Employee Management)
"""

View 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()

View 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] = []

View File

@@ -0,0 +1,3 @@
"""
給与設定・税率・保険料管理 (Settings Management)
"""

View 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()

View 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

View File

@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>給与管理 - 月次給与計算</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.filters {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.filters label {
margin-right: 15px;
}
.payroll-list {
margin-top: 20px;
}
.payroll-item {
padding: 15px;
border: 1px solid #ddd;
margin-bottom: 10px;
border-radius: 5px;
cursor: pointer;
}
.payroll-item:hover {
background: #f9f9f9;
}
.status-badge {
padding: 4px 8px;
border-radius: 3px;
font-size: 12px;
font-weight: bold;
}
.status-draft {
background: #ffc107;
color: #000;
}
.status-calculated {
background: #17a2b8;
color: #fff;
}
.status-approved {
background: #28a745;
color: #fff;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.form-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 15px;
}
.btn {
padding: 10px 20px;
margin-right: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-success {
background: #28a745;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.payroll-detail {
margin-top: 20px;
}
.detail-section {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.detail-row {
display: flex;
justify-content: space-between;
padding: 8px 0;
border-bottom: 1px solid #ddd;
}
.detail-row:last-child {
border-bottom: none;
}
.total-row {
font-weight: bold;
font-size: 1.2em;
color: #007bff;
}
</style>
</head>
<body>
<div class="container">
<h1>給与管理 - 月次給与計算</h1>
<div class="filters">
<label>
対象年月:
<input type="number" id="filterYear" style="width: 100px;" placeholder="年">
</label>
<label>
<input type="number" id="filterMonth" style="width: 80px;" min="1" max="12" placeholder="月">
</label>
<button class="btn btn-primary" onclick="loadPayrolls()">検索</button>
<button class="btn btn-success" onclick="showCalculateForm()">新規計算</button>
</div>
<div id="payrollList" class="payroll-list"></div>
<!-- 給与計算フォーム(モーダル風) -->
<div id="calculateModal" style="display:none; position:fixed; top:50px; left:50%; transform:translateX(-50%); width:800px; max-height:90vh; overflow-y:auto; background:white; padding:30px; border:2px solid #007bff; border-radius:10px; box-shadow:0 4px 8px rgba(0,0,0,0.2); z-index:1000;">
<h2>給与計算</h2>
<form id="calculateForm" onsubmit="calculatePayroll(event)">
<div class="form-group">
<label>従業員 *</label>
<select name="employee_id" id="employeeSelect" required>
<option value="">選択してください</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label>対象年 *</label>
<input type="number" name="payroll_year" required>
</div>
<div class="form-group">
<label>対象月 *</label>
<input type="number" name="payroll_month" min="1" max="12" required>
</div>
<div class="form-group">
<label>支給日 *</label>
<input type="date" name="payment_date" required>
</div>
</div>
<div class="detail-section">
<h3>勤怠情報</h3>
<div class="form-row">
<div class="form-group">
<label>出勤日数</label>
<input type="number" name="working_days" step="0.5" value="0">
</div>
<div class="form-group">
<label>勤務時間</label>
<input type="number" name="working_hours" step="0.5" value="0">
</div>
<div class="form-group">
<label>残業時間</label>
<input type="number" name="overtime_hours" step="0.5" value="0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>休日出勤時間</label>
<input type="number" name="holiday_hours" step="0.5" value="0">
</div>
<div class="form-group">
<label>遅刻時間</label>
<input type="number" name="late_hours" step="0.5" value="0">
</div>
<div class="form-group">
<label>欠勤日数</label>
<input type="number" name="absent_days" step="0.5" value="0">
</div>
</div>
</div>
<div class="detail-section">
<h3>その他</h3>
<div class="form-row">
<div class="form-group">
<label>その他手当</label>
<input type="number" name="other_allowance" step="0.01" value="0">
</div>
<div class="form-group">
<label>住民税</label>
<input type="number" name="resident_tax" step="0.01" value="0">
</div>
<div class="form-group">
<label>その他控除</label>
<input type="number" name="other_deduction" step="0.01" value="0">
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">計算実行</button>
<button type="button" class="btn btn-secondary" onclick="closeCalculateForm()">キャンセル</button>
</form>
</div>
<!-- 給与明細表示 -->
<div id="payrollDetail" class="payroll-detail" style="display:none;"></div>
</div>
<script>
const API_BASE = 'http://localhost:8000';
async function loadEmployees() {
const response = await fetch(`${API_BASE}/payroll/employees/?is_active=true`);
const employees = await response.json();
const select = document.getElementById('employeeSelect');
select.innerHTML = '<option value="">選択してください</option>' +
employees.map(emp => `<option value="${emp.employee_id}">${emp.employee_code} - ${emp.name}</option>`).join('');
}
async function loadPayrolls() {
const year = document.getElementById('filterYear').value;
const month = document.getElementById('filterMonth').value;
const params = new URLSearchParams();
if (year) params.append('payroll_year', year);
if (month) params.append('payroll_month', month);
try {
const response = await fetch(`${API_BASE}/payroll/calculation/?${params}`);
const payrolls = await response.json();
const html = payrolls.map(p => `
<div class="payroll-item" onclick="viewPayroll(${p.payroll_id})">
<div>
<strong>${p.payroll_year}${p.payroll_month}月</strong>
<span class="status-badge status-${p.status}">${getStatusLabel(p.status)}</span>
</div>
<div>
従業員ID: ${p.employee_id} | 支給日: ${p.payment_date}
</div>
<div>
<strong>差引支給額: ¥${Number(p.net_payment).toLocaleString()}</strong>
(総支給: ¥${Number(p.total_payment).toLocaleString()} - 控除: ¥${Number(p.total_deduction).toLocaleString()})
</div>
</div>
`).join('');
document.getElementById('payrollList').innerHTML = html || '<p>給与データがありません</p>';
} catch (error) {
alert('給与一覧の取得に失敗しました');
console.error(error);
}
}
function getStatusLabel(status) {
const labels = {
'draft': '下書き',
'calculated': '計算済み',
'approved': '承認済み',
'paid': '支払済み'
};
return labels[status] || status;
}
function showCalculateForm() {
document.getElementById('calculateModal').style.display = 'block';
loadEmployees();
// デフォルト値設定
const now = new Date();
document.querySelector('[name="payroll_year"]').value = now.getFullYear();
document.querySelector('[name="payroll_month"]').value = now.getMonth() + 1;
}
function closeCalculateForm() {
document.getElementById('calculateModal').style.display = 'none';
document.getElementById('calculateForm').reset();
}
async function calculatePayroll(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = {};
formData.forEach((value, key) => {
data[key] = isNaN(value) || value === '' ? value : Number(value);
});
try {
const response = await fetch(`${API_BASE}/payroll/calculation/calculate`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
const result = await response.json();
alert('給与計算が完了しました');
closeCalculateForm();
viewPayroll(result.payroll_id);
} else {
const error = await response.json();
alert('計算に失敗しました: ' + error.detail);
}
} catch (error) {
alert('計算に失敗しました');
console.error(error);
}
}
async function viewPayroll(payrollId) {
try {
const response = await fetch(`${API_BASE}/payroll/calculation/${payrollId}`);
const payroll = await response.json();
const html = `
<h2>${payroll.payroll_year}${payroll.payroll_month}月 給与明細</h2>
<p>従業員ID: ${payroll.employee_id} | 支給日: ${payroll.payment_date}</p>
<p>ステータス: <span class="status-badge status-${payroll.status}">${getStatusLabel(payroll.status)}</span></p>
<div class="detail-section">
<h3>勤怠情報</h3>
<div class="detail-row"><span>出勤日数:</span><span>${payroll.working_days}日</span></div>
<div class="detail-row"><span>勤務時間:</span><span>${payroll.working_hours}時間</span></div>
<div class="detail-row"><span>残業時間:</span><span>${payroll.overtime_hours}時間</span></div>
<div class="detail-row"><span>休日出勤:</span><span>${payroll.holiday_hours}時間</span></div>
<div class="detail-row"><span>欠勤日数:</span><span>${payroll.absent_days}日</span></div>
</div>
<div class="detail-section">
<h3>支給項目</h3>
<div class="detail-row"><span>基本給:</span><span>¥${Number(payroll.base_salary).toLocaleString()}</span></div>
<div class="detail-row"><span>残業手当:</span><span>¥${Number(payroll.overtime_pay).toLocaleString()}</span></div>
<div class="detail-row"><span>休日手当:</span><span>¥${Number(payroll.holiday_pay).toLocaleString()}</span></div>
<div class="detail-row"><span>通勤手当:</span><span>¥${Number(payroll.commute_allowance).toLocaleString()}</span></div>
<div class="detail-row"><span>その他手当:</span><span>¥${Number(payroll.other_allowance).toLocaleString()}</span></div>
<div class="detail-row total-row"><span>総支給額:</span><span>¥${Number(payroll.total_payment).toLocaleString()}</span></div>
</div>
<div class="detail-section">
<h3>控除項目</h3>
<div class="detail-row"><span>健康保険:</span><span>¥${Number(payroll.health_insurance).toLocaleString()}</span></div>
<div class="detail-row"><span>厚生年金:</span><span>¥${Number(payroll.pension_insurance).toLocaleString()}</span></div>
<div class="detail-row"><span>雇用保険:</span><span>¥${Number(payroll.employment_insurance).toLocaleString()}</span></div>
<div class="detail-row"><span>所得税:</span><span>¥${Number(payroll.income_tax).toLocaleString()}</span></div>
<div class="detail-row"><span>住民税:</span><span>¥${Number(payroll.resident_tax).toLocaleString()}</span></div>
<div class="detail-row"><span>その他控除:</span><span>¥${Number(payroll.other_deduction).toLocaleString()}</span></div>
<div class="detail-row total-row"><span>総控除額:</span><span>¥${Number(payroll.total_deduction).toLocaleString()}</span></div>
</div>
<div class="detail-section" style="background:#e7f3ff;">
<div class="detail-row total-row" style="font-size:1.5em;">
<span>差引支給額:</span>
<span>¥${Number(payroll.net_payment).toLocaleString()}</span>
</div>
</div>
${payroll.status === 'calculated' ? `
<button class="btn btn-success" onclick="approvePayroll(${payroll.payroll_id})">承認</button>
<button class="btn btn-primary" onclick="recalculatePayroll(${payroll.payroll_id})">再計算</button>
` : ''}
<button class="btn btn-secondary" onclick="document.getElementById('payrollDetail').style.display='none'">閉じる</button>
`;
document.getElementById('payrollDetail').innerHTML = html;
document.getElementById('payrollDetail').style.display = 'block';
document.getElementById('payrollDetail').scrollIntoView({ behavior: 'smooth' });
} catch (error) {
alert('給与明細の取得に失敗しました');
console.error(error);
}
}
async function approvePayroll(payrollId) {
if (!confirm('この給与を承認しますか?')) return;
try {
const response = await fetch(`${API_BASE}/payroll/calculation/${payrollId}/approve`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ approved_by: 'admin' })
});
if (response.ok) {
alert('承認しました');
viewPayroll(payrollId);
loadPayrolls();
} else {
const error = await response.json();
alert('承認に失敗しました: ' + error.detail);
}
} catch (error) {
alert('承認に失敗しました');
console.error(error);
}
}
async function recalculatePayroll(payrollId) {
if (!confirm('給与を再計算しますか?')) return;
try {
const response = await fetch(`${API_BASE}/payroll/calculation/${payrollId}/recalculate`, {
method: 'POST'
});
if (response.ok) {
alert('再計算が完了しました');
viewPayroll(payrollId);
} else {
const error = await response.json();
alert('再計算に失敗しました: ' + error.detail);
}
} catch (error) {
alert('再計算に失敗しました');
console.error(error);
}
}
// 初期化
const now = new Date();
document.getElementById('filterYear').value = now.getFullYear();
document.getElementById('filterMonth').value = now.getMonth() + 1;
loadPayrolls();
</script>
</body>
</html>

View File

@@ -0,0 +1,302 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>給与管理 - 従業員管理</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.nav-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.nav-tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: #f0f0f0;
border-radius: 5px 5px 0 0;
}
.nav-tab.active {
background: #007bff;
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.employee-list {
margin-top: 20px;
}
.employee-item {
padding: 15px;
border: 1px solid #ddd;
margin-bottom: 10px;
border-radius: 5px;
cursor: pointer;
}
.employee-item:hover {
background: #f9f9f9;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group select, .form-group textarea {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn {
padding: 10px 20px;
margin-right: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.dependent-list {
margin-top: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.dependent-item {
padding: 10px;
border: 1px solid #ddd;
margin-bottom: 10px;
background: white;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="container">
<h1>給与管理 - 従業員管理</h1>
<div class="nav-tabs">
<button class="nav-tab active" onclick="showTab('list')">従業員一覧</button>
<button class="nav-tab" onclick="showTab('create')">新規登録</button>
<button class="nav-tab" onclick="showTab('detail')" id="detailTab" style="display:none;">従業員詳細</button>
</div>
<!-- 従業員一覧タブ -->
<div id="tab-list" class="tab-content active">
<div class="filters">
<label>
<input type="checkbox" id="filterActive" checked onchange="loadEmployees()">
在職中のみ表示
</label>
</div>
<div id="employeeList" class="employee-list"></div>
</div>
<!-- 新規登録タブ -->
<div id="tab-create" class="tab-content">
<h2>従業員の新規登録</h2>
<form id="createForm" onsubmit="createEmployee(event)">
<div class="form-group">
<label>従業員コード *</label>
<input type="text" name="employee_code" required>
</div>
<div class="form-group">
<label>氏名 *</label>
<input type="text" name="name" required>
</div>
<div class="form-group">
<label>フリガナ</label>
<input type="text" name="name_kana">
</div>
<div class="form-group">
<label>メールアドレス</label>
<input type="email" name="email">
</div>
<div class="form-group">
<label>電話番号</label>
<input type="tel" name="phone">
</div>
<div class="form-group">
<label>入社日 *</label>
<input type="date" name="hire_date" required>
</div>
<div class="form-group">
<label>銀行名</label>
<input type="text" name="bank_name">
</div>
<div class="form-group">
<label>支店名</label>
<input type="text" name="bank_branch">
</div>
<div class="form-group">
<label>口座番号</label>
<input type="text" name="bank_account_number">
</div>
<div class="form-group">
<label>口座種別</label>
<select name="bank_account_type">
<option value="">選択してください</option>
<option value="普通">普通</option>
<option value="当座">当座</option>
</select>
</div>
<div class="form-group">
<label>備考</label>
<textarea name="notes" rows="3"></textarea>
</div>
<button type="submit" class="btn btn-primary">登録</button>
<button type="button" class="btn btn-secondary" onclick="showTab('list')">キャンセル</button>
</form>
</div>
<!-- 従業員詳細タブ -->
<div id="tab-detail" class="tab-content">
<div id="employeeDetail"></div>
</div>
</div>
<script>
const API_BASE = 'http://localhost:8000';
let currentEmployee = null;
function showTab(tabName) {
document.querySelectorAll('.nav-tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
if (tabName === 'list') {
document.querySelector('.nav-tab').classList.add('active');
document.getElementById('tab-list').classList.add('active');
loadEmployees();
} else if (tabName === 'create') {
document.querySelectorAll('.nav-tab')[1].classList.add('active');
document.getElementById('tab-create').classList.add('active');
} else if (tabName === 'detail') {
document.getElementById('detailTab').classList.add('active');
document.getElementById('tab-detail').classList.add('active');
}
}
async function loadEmployees() {
const isActive = document.getElementById('filterActive').checked;
const url = `${API_BASE}/payroll/employees/${isActive ? '?is_active=true' : ''}`;
try {
const response = await fetch(url);
const employees = await response.json();
const html = employees.map(emp => `
<div class="employee-item" onclick="viewEmployee(${emp.employee_id})">
<strong>${emp.employee_code}</strong> - ${emp.name}
${emp.name_kana ? `(${emp.name_kana})` : ''}
<br>
<small>入社日: ${emp.hire_date} ${emp.termination_date ? ` | 退職日: ${emp.termination_date}` : ''}</small>
</div>
`).join('');
document.getElementById('employeeList').innerHTML = html || '<p>従業員が登録されていません</p>';
} catch (error) {
alert('従業員一覧の取得に失敗しました');
console.error(error);
}
}
async function createEmployee(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// 空の値を除外
Object.keys(data).forEach(key => {
if (data[key] === '') {
delete data[key];
}
});
try {
const response = await fetch(`${API_BASE}/payroll/employees/`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
alert('従業員を登録しました');
form.reset();
showTab('list');
} else {
const error = await response.json();
alert('登録に失敗しました: ' + error.detail);
}
} catch (error) {
alert('登録に失敗しました');
console.error(error);
}
}
async function viewEmployee(employeeId) {
try {
const response = await fetch(`${API_BASE}/payroll/employees/${employeeId}`);
const employee = await response.json();
currentEmployee = employee;
const html = `
<h2>${employee.name} (${employee.employee_code})</h2>
<p><strong>フリガナ:</strong> ${employee.name_kana || '-'}</p>
<p><strong>メール:</strong> ${employee.email || '-'}</p>
<p><strong>電話:</strong> ${employee.phone || '-'}</p>
<p><strong>入社日:</strong> ${employee.hire_date}</p>
<p><strong>退職日:</strong> ${employee.termination_date || '-'}</p>
<p><strong>銀行:</strong> ${employee.bank_name || '-'} ${employee.bank_branch || ''}</p>
<p><strong>口座:</strong> ${employee.bank_account_type || ''} ${employee.bank_account_number || '-'}</p>
<p><strong>備考:</strong> ${employee.notes || '-'}</p>
<div class="dependent-list">
<h3>扶養家族</h3>
${employee.dependents.map(dep => `
<div class="dependent-item">
<strong>${dep.name}</strong> (${dep.relationship})
${dep.birth_date ? ` - 生年月日: ${dep.birth_date}` : ''}
${dep.is_spouse ? ' <span style="color:blue;">[配偶者]</span>' : ''}
${dep.is_disabled ? ' <span style="color:red;">[障害者]</span>' : ''}
<br><small>有効期間: ${dep.valid_from} ${dep.valid_to || '現在'}</small>
</div>
`).join('') || '<p>扶養家族が登録されていません</p>'}
</div>
<button class="btn btn-secondary" onclick="showTab('list')">戻る</button>
`;
document.getElementById('employeeDetail').innerHTML = html;
document.getElementById('detailTab').style.display = 'block';
showTab('detail');
} catch (error) {
alert('従業員情報の取得に失敗しました');
console.error(error);
}
}
// 初期化
loadEmployees();
</script>
</body>
</html>

View File

@@ -0,0 +1,291 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>給与管理 - 給与設定</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.employee-select {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border-radius: 5px;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn {
padding: 10px 20px;
margin-right: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
table th, table td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}
table th {
background: #f0f0f0;
}
</style>
</head>
<body>
<div class="container">
<h1>給与管理 - 給与設定</h1>
<div class="employee-select">
<label><strong>従業員を選択:</strong></label>
<select id="employeeSelect" onchange="loadSalarySettings()">
<option value="">従業員を選択してください</option>
</select>
</div>
<div id="settingsContent" style="display:none;">
<h2>給与設定履歴</h2>
<button class="btn btn-primary" onclick="showAddForm()">新規設定追加</button>
<div id="settingsList"></div>
<div id="addForm" style="display:none; margin-top:20px; padding:20px; border:2px solid #007bff; border-radius:5px;">
<h3>給与設定の追加</h3>
<form onsubmit="saveSalarySetting(event)">
<div class="form-group">
<label>基本給 (円) *</label>
<input type="number" name="base_salary" step="0.01" required>
</div>
<div class="form-group">
<label>時給 (円)</label>
<input type="number" name="hourly_rate" step="0.01">
<small>時給制の場合に入力してください</small>
</div>
<div class="form-group">
<label>雇用形態 *</label>
<select name="employment_type" required>
<option value="">選択してください</option>
<option value="正社員">正社員</option>
<option value="契約社員">契約社員</option>
<option value="パート">パート</option>
<option value="アルバイト">アルバイト</option>
</select>
</div>
<div class="form-group">
<label>支給形態 *</label>
<select name="payment_type" required>
<option value="">選択してください</option>
<option value="月給">月給</option>
<option value="時給">時給</option>
<option value="日給">日給</option>
</select>
</div>
<div class="form-group">
<label>通勤手当 (円)</label>
<input type="number" name="commute_allowance" step="0.01" value="0">
</div>
<div class="form-group">
<label>その他手当 (円)</label>
<input type="number" name="other_allowance" step="0.01" value="0">
</div>
<div class="form-group">
<label>適用開始日 *</label>
<input type="date" name="valid_from" required>
</div>
<div class="form-group">
<label>適用終了日</label>
<input type="date" name="valid_to">
<small>通常は空欄のままにしてください</small>
</div>
<button type="submit" class="btn btn-primary">保存</button>
<button type="button" class="btn btn-secondary" onclick="hideAddForm()">キャンセル</button>
</form>
</div>
<div id="currentSetting" style="margin-top:30px; padding:20px; background:#e7f3ff; border-radius:5px;">
<h3>現在の給与設定</h3>
<div id="currentSettingContent"></div>
</div>
</div>
</div>
<script>
const API_BASE = 'http://localhost:8000';
let currentEmployeeId = null;
async function loadEmployees() {
try {
const response = await fetch(`${API_BASE}/payroll/employees/?is_active=true`);
const employees = await response.json();
const select = document.getElementById('employeeSelect');
select.innerHTML = '<option value="">従業員を選択してください</option>' +
employees.map(emp => `<option value="${emp.employee_id}">${emp.employee_code} - ${emp.name}</option>`).join('');
} catch (error) {
alert('従業員一覧の取得に失敗しました');
console.error(error);
}
}
async function loadSalarySettings() {
const employeeId = document.getElementById('employeeSelect').value;
if (!employeeId) {
document.getElementById('settingsContent').style.display = 'none';
return;
}
currentEmployeeId = employeeId;
document.getElementById('settingsContent').style.display = 'block';
try {
// 給与設定履歴を取得
const response = await fetch(`${API_BASE}/payroll/settings/salary/${employeeId}`);
const settings = await response.json();
const html = `
<table>
<thead>
<tr>
<th>適用期間</th>
<th>基本給</th>
<th>時給</th>
<th>雇用形態</th>
<th>支給形態</th>
<th>通勤手当</th>
<th>その他手当</th>
</tr>
</thead>
<tbody>
${settings.map(s => `
<tr>
<td>${s.valid_from} ${s.valid_to || '現在'}</td>
<td>¥${Number(s.base_salary).toLocaleString()}</td>
<td>${s.hourly_rate ? '¥' + Number(s.hourly_rate).toLocaleString() : '-'}</td>
<td>${s.employment_type}</td>
<td>${s.payment_type}</td>
<td>¥${Number(s.commute_allowance).toLocaleString()}</td>
<td>¥${Number(s.other_allowance).toLocaleString()}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('settingsList').innerHTML = html;
// 現在の設定を取得
loadCurrentSetting(employeeId);
} catch (error) {
alert('給与設定の取得に失敗しました');
console.error(error);
}
}
async function loadCurrentSetting(employeeId) {
try {
const response = await fetch(`${API_BASE}/payroll/settings/salary/${employeeId}/current`);
if (response.ok) {
const setting = await response.json();
const html = `
<div style="display:grid; grid-template-columns: repeat(2, 1fr); gap:15px;">
<div><strong>基本給:</strong> ¥${Number(setting.base_salary).toLocaleString()}</div>
<div><strong>時給:</strong> ${setting.hourly_rate ? '¥' + Number(setting.hourly_rate).toLocaleString() : '-'}</div>
<div><strong>雇用形態:</strong> ${setting.employment_type}</div>
<div><strong>支給形態:</strong> ${setting.payment_type}</div>
<div><strong>通勤手当:</strong> ¥${Number(setting.commute_allowance).toLocaleString()}</div>
<div><strong>その他手当:</strong> ¥${Number(setting.other_allowance).toLocaleString()}</div>
<div><strong>適用期間:</strong> ${setting.valid_from} ${setting.valid_to || '現在'}</div>
</div>
`;
document.getElementById('currentSettingContent').innerHTML = html;
} else {
document.getElementById('currentSettingContent').innerHTML = '<p>給与設定が登録されていません</p>';
}
} catch (error) {
document.getElementById('currentSettingContent').innerHTML = '<p>給与設定の取得に失敗しました</p>';
}
}
function showAddForm() {
document.getElementById('addForm').style.display = 'block';
// デフォルト値設定
document.querySelector('[name="valid_from"]').value = new Date().toISOString().split('T')[0];
}
function hideAddForm() {
document.getElementById('addForm').style.display = 'none';
document.querySelector('#addForm form').reset();
}
async function saveSalarySetting(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
data.employee_id = parseInt(currentEmployeeId);
data.base_salary = parseFloat(data.base_salary) || 0;
data.commute_allowance = parseFloat(data.commute_allowance) || 0;
data.other_allowance = parseFloat(data.other_allowance) || 0;
if (data.hourly_rate) {
data.hourly_rate = parseFloat(data.hourly_rate);
} else {
delete data.hourly_rate;
}
if (!data.valid_to) {
delete data.valid_to;
}
try {
const response = await fetch(`${API_BASE}/payroll/settings/salary`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
alert('給与設定を保存しました');
hideAddForm();
loadSalarySettings();
} else {
const error = await response.json();
alert('保存に失敗しました: ' + error.detail);
}
} catch (error) {
alert('保存に失敗しました');
console.error(error);
}
}
// 初期化
loadEmployees();
</script>
</body>
</html>

View File

@@ -0,0 +1,326 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>給与管理 - 設定</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.nav-tabs {
display: flex;
gap: 10px;
margin-bottom: 20px;
border-bottom: 2px solid #ddd;
}
.nav-tab {
padding: 10px 20px;
cursor: pointer;
border: none;
background: #f0f0f0;
border-radius: 5px 5px 0 0;
}
.nav-tab.active {
background: #007bff;
color: white;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input, .form-group select {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
.btn {
padding: 10px 20px;
margin-right: 10px;
border: none;
border-radius: 4px;
cursor: pointer;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
table th, table td {
border: 1px solid #ddd;
padding: 10px;
text-align: left;
}
table th {
background: #f0f0f0;
}
</style>
</head>
<body>
<div class="container">
<h1>給与管理 - 設定</h1>
<div class="nav-tabs">
<button class="nav-tab active" onclick="showTab('insurance')">保険料率</button>
<button class="nav-tab" onclick="showTab('tax')">所得税率表</button>
</div>
<!-- 保険料率タブ -->
<div id="tab-insurance" class="tab-content active">
<h2>社会保険料率設定</h2>
<button class="btn btn-primary" onclick="showInsuranceForm()">新規追加</button>
<div id="insuranceList"></div>
<div id="insuranceForm" style="display:none; margin-top:20px; padding:20px; border:2px solid #007bff; border-radius:5px;">
<h3>保険料率の登録</h3>
<form onsubmit="saveInsuranceRate(event)">
<div class="form-group">
<label>保険種類 *</label>
<select name="rate_type" required>
<option value="">選択してください</option>
<option value="健康保険">健康保険</option>
<option value="厚生年金">厚生年金</option>
<option value="雇用保険">雇用保険</option>
<option value="労災保険">労災保険</option>
</select>
</div>
<div class="form-group">
<label>従業員負担率 (%) *</label>
<input type="number" name="employee_rate" step="0.0001" required placeholder="例: 5.0 (5%)">
</div>
<div class="form-group">
<label>会社負担率 (%) *</label>
<input type="number" name="employer_rate" step="0.0001" required placeholder="例: 5.0 (5%)">
</div>
<div class="form-group">
<label>適用開始日 *</label>
<input type="date" name="valid_from" required>
</div>
<div class="form-group">
<label>適用終了日</label>
<input type="date" name="valid_to">
</div>
<div class="form-group">
<label>備考</label>
<input type="text" name="notes">
</div>
<button type="submit" class="btn btn-primary">登録</button>
<button type="button" class="btn btn-secondary" onclick="hideInsuranceForm()">キャンセル</button>
</form>
</div>
</div>
<!-- 所得税率表タブ -->
<div id="tab-tax" class="tab-content">
<h2>所得税率表</h2>
<div class="form-group" style="max-width:300px;">
<label>扶養人数でフィルター</label>
<select id="filterDependents" onchange="loadIncomeTax()">
<option value="">全て</option>
<option value="0">0人</option>
<option value="1">1人</option>
<option value="2">2人</option>
<option value="3">3人</option>
<option value="4">4人</option>
<option value="5">5人以上</option>
</select>
</div>
<button class="btn btn-primary" onclick="document.getElementById('csvFile').click()">CSVインポート</button>
<input type="file" id="csvFile" accept=".csv" style="display:none;" onchange="importTaxTable(event)">
<div id="taxList"></div>
</div>
</div>
<script>
const API_BASE = 'http://localhost:8000';
function showTab(tabName) {
document.querySelectorAll('.nav-tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
if (tabName === 'insurance') {
document.querySelectorAll('.nav-tab')[0].classList.add('active');
document.getElementById('tab-insurance').classList.add('active');
loadInsuranceRates();
} else if (tabName === 'tax') {
document.querySelectorAll('.nav-tab')[1].classList.add('active');
document.getElementById('tab-tax').classList.add('active');
loadIncomeTax();
}
}
function showInsuranceForm() {
document.getElementById('insuranceForm').style.display = 'block';
}
function hideInsuranceForm() {
document.getElementById('insuranceForm').style.display = 'none';
document.querySelector('#insuranceForm form').reset();
}
async function loadInsuranceRates() {
try {
const response = await fetch(`${API_BASE}/payroll/settings/insurance-rates`);
const rates = await response.json();
const html = `
<table>
<thead>
<tr>
<th>保険種類</th>
<th>従業員負担率</th>
<th>会社負担率</th>
<th>適用期間</th>
<th>備考</th>
</tr>
</thead>
<tbody>
${rates.map(rate => `
<tr>
<td>${rate.rate_type}</td>
<td>${(parseFloat(rate.employee_rate) * 100).toFixed(2)}%</td>
<td>${(parseFloat(rate.employer_rate) * 100).toFixed(2)}%</td>
<td>${rate.valid_from} ${rate.valid_to || '現在'}</td>
<td>${rate.notes || '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('insuranceList').innerHTML = html;
} catch (error) {
alert('保険料率の取得に失敗しました');
console.error(error);
}
}
async function saveInsuranceRate(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
// パーセントを小数に変換
data.employee_rate = parseFloat(data.employee_rate) / 100;
data.employer_rate = parseFloat(data.employer_rate) / 100;
// 空の値を除外
if (!data.valid_to) delete data.valid_to;
if (!data.notes) delete data.notes;
try {
const response = await fetch(`${API_BASE}/payroll/settings/insurance-rates`, {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
if (response.ok) {
alert('保険料率を登録しました');
hideInsuranceForm();
loadInsuranceRates();
} else {
const error = await response.json();
alert('登録に失敗しました: ' + error.detail);
}
} catch (error) {
alert('登録に失敗しました');
console.error(error);
}
}
async function loadIncomeTax() {
const dependentsFilter = document.getElementById('filterDependents').value;
const params = dependentsFilter ? `?dependents_count=${dependentsFilter}` : '';
try {
const response = await fetch(`${API_BASE}/payroll/settings/income-tax${params}`);
const taxes = await response.json();
const html = `
<table>
<thead>
<tr>
<th>扶養人数</th>
<th>月収From</th>
<th>月収To</th>
<th>税額</th>
<th>適用期間</th>
</tr>
</thead>
<tbody>
${taxes.map(tax => `
<tr>
<td>${tax.dependents_count}人</td>
<td>¥${Number(tax.monthly_income_from).toLocaleString()}</td>
<td>¥${Number(tax.monthly_income_to).toLocaleString()}</td>
<td>¥${Number(tax.tax_amount).toLocaleString()}</td>
<td>${tax.valid_from} ${tax.valid_to || '現在'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
document.getElementById('taxList').innerHTML = html;
} catch (error) {
alert('所得税率表の取得に失敗しました');
console.error(error);
}
}
async function importTaxTable(event) {
const file = event.target.files[0];
if (!file) return;
const formData = new FormData();
formData.append('file', file);
const validFrom = prompt('適用開始日を入力してください (YYYY-MM-DD):', new Date().toISOString().split('T')[0]);
if (!validFrom) return;
try {
const response = await fetch(`${API_BASE}/payroll/settings/income-tax/import?valid_from=${validFrom}`, {
method: 'POST',
body: formData
});
if (response.ok) {
const result = await response.json();
alert(result.message);
loadIncomeTax();
} else {
const error = await response.json();
alert('インポートに失敗しました: ' + error.detail);
}
} catch (error) {
alert('インポートに失敗しました');
console.error(error);
}
event.target.value = '';
}
// 初期化
loadInsuranceRates();
</script>
</body>
</html>

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>給与管理システム</title>
<link rel="stylesheet" href="/css/style.css">
<style>
.menu-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
margin-top: 30px;
}
.menu-card {
padding: 30px;
border: 2px solid #007bff;
border-radius: 10px;
text-align: center;
cursor: pointer;
transition: all 0.3s;
}
.menu-card:hover {
background: #007bff;
color: white;
transform: translateY(-5px);
box-shadow: 0 5px 15px rgba(0,123,255,0.3);
}
.menu-card h2 {
margin: 0 0 15px 0;
font-size: 1.5em;
}
.menu-card p {
margin: 0;
opacity: 0.8;
}
.menu-card:hover p {
opacity: 1;
}
.icon {
font-size: 3em;
margin-bottom: 15px;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
text-decoration: none;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<a href="../index.html" class="back-link">← メインメニューに戻る</a>
<h1>給与管理システム</h1>
<p>従業員の給与計算、扶養管理、保険料・税金の管理を行います</p>
<div class="menu-grid">
<div class="menu-card" onclick="location.href='payroll-employees.html'">
<div class="icon">👥</div>
<h2>従業員管理</h2>
<p>従業員の基本情報、扶養家族情報を管理します</p>
</div>
<div class="menu-card" onclick="location.href='payroll-salary-settings.html'">
<div class="icon">💰</div>
<h2>給与設定</h2>
<p>各従業員の給与設定、基本給、手当などを管理します</p>
</div>
<div class="menu-card" onclick="location.href='payroll-calculation.html'">
<div class="icon">🧮</div>
<h2>月次給与計算</h2>
<p>毎月の給与計算、給与明細の作成・承認を行います</p>
</div>
<div class="menu-card" onclick="location.href='payroll-settings.html'">
<div class="icon">⚙️</div>
<h2>税率・保険料設定</h2>
<p>社会保険料率、所得税率表の管理を行います</p>
</div>
</div>
<div style="margin-top: 50px; padding: 20px; background: #f0f8ff; border-radius: 5px;">
<h3>📋 給与システムの使い方</h3>
<ol>
<li><strong>従業員管理</strong>で従業員を登録し、扶養家族情報を入力します</li>
<li><strong>給与設定</strong>で各従業員の基本給や手当を設定します</li>
<li><strong>税率・保険料設定</strong>で社会保険料率や所得税率表を確認・更新します</li>
<li><strong>月次給与計算</strong>で勤怠情報を入力し、給与を計算・承認します</li>
</ol>
</div>
</div>
</body>
</html>