This commit is contained in:
admin
2026-01-21 10:58:19 +09:00
parent 8a00de8f03
commit 86020ada5c
36 changed files with 3417 additions and 363 deletions

View File

@@ -11,20 +11,22 @@ class BonusCalculationService:
"""賞与計算サービス"""
@staticmethod
def get_insurance_rate(rate_type: str, target_date: date) -> Dict[str, Any]:
"""保険料率を取得"""
def get_insurance_rate(rate_type: str, target_date: date, calculation_target: str = '賞与') -> Dict[str, Any]:
"""保険料率を取得(賞与用)"""
with get_connection() as conn:
with conn.cursor() as cur:
# rate_yearで年度を判定
target_year = target_date.year
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
AND rate_year = %s
AND calculation_target = %s
ORDER BY rate_id DESC
LIMIT 1
""",
(rate_type, target_date, target_date)
(rate_type, target_year, calculation_target)
)
return cur.fetchone()
@@ -134,10 +136,15 @@ class BonusCalculationService:
)
employee_info = cur.fetchone()
# 年齢を計算
# 年齢を計算介護保険は40歳以上65歳未満が対象
age = 0
if employee_info and employee_info["birth_date"]:
age = (payment_date - employee_info["birth_date"]).days // 365
birth_date = employee_info["birth_date"]
# より正確な年齢計算
age = payment_date.year - birth_date.year
# 誕生日がまだ来ていない場合は-1
if (payment_date.month, payment_date.day) < (birth_date.month, birth_date.day):
age -= 1
# 健康保険賞与額の1/1000で計算
health_insurance_rate = BonusCalculationService.get_insurance_rate("健康保険", payment_date)

View File

@@ -81,6 +81,7 @@ class MonthlyPayroll(MonthlyPayrollBase):
# 控除項目
health_insurance: Decimal
care_insurance: Decimal
pension_insurance: Decimal
employment_insurance: Decimal
income_tax: Decimal

View File

@@ -12,17 +12,21 @@ class PayrollCalculationService:
@staticmethod
def get_active_dependents_count(employee_id: int, target_date: date) -> int:
"""有効な扶養家族数を取得"""
"""有効な扶養家族数を取得源泉税控除対象のみ19歳以上"""
with get_connection() as conn:
with conn.cursor() as cur:
# その年の12月31日時点で19歳以上の扶養家族のみをカウント
# 16-18歳は住民税のみ対象なので源泉税控除対象外
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)
AND birth_date IS NOT NULL
AND EXTRACT(YEAR FROM AGE(DATE(EXTRACT(YEAR FROM %s) || '-12-31'), birth_date)) >= 19
""",
(employee_id, target_date, target_date)
(employee_id, target_date, target_date, target_date)
)
result = cur.fetchone()
return result["count"] if result else 0
@@ -46,20 +50,22 @@ class PayrollCalculationService:
return cur.fetchone()
@staticmethod
def get_insurance_rate(rate_type: str, target_date: date) -> Dict[str, Any]:
def get_insurance_rate(rate_type: str, target_date: date, calculation_target: str = '給与') -> Dict[str, Any]:
"""保険料率を取得"""
with get_connection() as conn:
with conn.cursor() as cur:
# rate_yearで年度を判定
target_year = target_date.year
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
AND rate_year = %s
AND calculation_target = %s
ORDER BY rate_id DESC
LIMIT 1
""",
(rate_type, target_date, target_date)
(rate_type, target_year, calculation_target)
)
return cur.fetchone()
@@ -202,10 +208,15 @@ class PayrollCalculationService:
)
employee_info = cur.fetchone()
# 年齢を計算介護保険は40歳以上が対象
# 年齢を計算介護保険は40歳以上65歳未満が対象)
age = 0
if employee_info and employee_info["birth_date"]:
age = (payment_date - employee_info["birth_date"]).days // 365
birth_date = employee_info["birth_date"]
# より正確な年齢計算
age = payment_date.year - birth_date.year
# 誕生日がまだ来ていない場合は-1
if (payment_date.month, payment_date.day) < (birth_date.month, birth_date.day):
age -= 1
# 健康保険(上限適用後の標準報酬月額で計算)
health_insurance_rate = PayrollCalculationService.get_insurance_rate("健康保険", payment_date)

View File

@@ -18,19 +18,24 @@ def create_employee(employee: schemas.EmployeeCreate):
cur.execute(
"""
INSERT INTO employees (
employee_code, name, name_kana, birth_date, gender,
employee_code, name, name_kana, birth_date, gender, my_number,
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)
bank_name, bank_branch, bank_account_number, bank_account_type, notes,
photo_url, my_number_card_image_url, residence_card_image_url,
health_insurance_card_image_url, other_id_image_url
) VALUES (%s, %s, %s, %s, %s, %s, %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.birth_date, employee.gender, employee.my_number,
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
employee.bank_account_number, employee.bank_account_type, employee.notes,
employee.photo_url, employee.my_number_card_image_url,
employee.residence_card_image_url, employee.health_insurance_card_image_url,
employee.other_id_image_url
),
)
result = cur.fetchone()

View File

@@ -54,6 +54,7 @@ class EmployeeBase(BaseModel):
name_kana: Optional[str] = None
birth_date: Optional[date] = None
gender: Optional[str] = None
my_number: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
postal_code: Optional[str] = None
@@ -66,6 +67,11 @@ class EmployeeBase(BaseModel):
bank_account_number: Optional[str] = None
bank_account_type: Optional[str] = None # 普通/当座
notes: Optional[str] = None
photo_url: Optional[str] = None
my_number_card_image_url: Optional[str] = None
residence_card_image_url: Optional[str] = None
health_insurance_card_image_url: Optional[str] = None
other_id_image_url: Optional[str] = None
class EmployeeCreate(EmployeeBase):
@@ -80,6 +86,7 @@ class EmployeeUpdate(BaseModel):
name_kana: Optional[str] = None
birth_date: Optional[date] = None
gender: Optional[str] = None
my_number: Optional[str] = None
email: Optional[EmailStr] = None
phone: Optional[str] = None
postal_code: Optional[str] = None
@@ -92,6 +99,11 @@ class EmployeeUpdate(BaseModel):
bank_account_number: Optional[str] = None
bank_account_type: Optional[str] = None
notes: Optional[str] = None
photo_url: Optional[str] = None
my_number_card_image_url: Optional[str] = None
residence_card_image_url: Optional[str] = None
health_insurance_card_image_url: Optional[str] = None
other_id_image_url: Optional[str] = None
class Employee(EmployeeBase):

View File

@@ -427,7 +427,7 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None):
# .xlsファイルの処理完了
conn.commit()
print(f"インポート完了: {imported_count}件のデータをインポートしました")
return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました"}
return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました", "year": extracted_year}
else:
# .xlsx形式 - openpyxlを使用
@@ -568,7 +568,7 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None):
# .xlsxファイルの処理完了
conn.commit()
print(f"インポート完了: {imported_count}件のデータをインポートしました")
return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました"}
return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました", "year": extracted_year}
# CSV file処理
else:
@@ -611,35 +611,75 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None):
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()
@router.get("/income-tax/years")
def get_income_tax_years():
"""所得税率表の年度一覧を取得"""
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)
)
cur.execute(
"""
SELECT DISTINCT tax_year
FROM income_tax_table
WHERE tax_year IS NOT NULL
ORDER BY tax_year DESC
"""
)
years = [row['tax_year'] for row in cur.fetchall()]
return {"years": years}
@router.get("/income-tax", response_model=List[schemas.IncomeTaxTable])
def get_income_tax_table(target_date: date = None, dependents_count: int = None, tax_year: int = None):
"""所得税率表を取得"""
with get_connection() as conn:
with conn.cursor() as cur:
# tax_yearが指定されている場合はそれを優先
if tax_year is not None:
if dependents_count is not None:
cur.execute(
"""
SELECT * FROM income_tax_table
WHERE tax_year = %s
AND dependents_count = %s
ORDER BY monthly_income_from
""",
(tax_year, dependents_count)
)
else:
cur.execute(
"""
SELECT * FROM income_tax_table
WHERE tax_year = %s
ORDER BY dependents_count, monthly_income_from
""",
(tax_year,)
)
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)
)
# target_dateで検索
if target_date is None:
target_date = date.today()
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()

View File

@@ -0,0 +1,358 @@
from fastapi import APIRouter, UploadFile, File, HTTPException, Form
from fastapi.responses import JSONResponse
from typing import List, Optional
from pydantic import BaseModel
from decimal import Decimal
import openpyxl
from io import BytesIO
from app.core.database import get_connection
router = APIRouter(prefix="/payroll/settings/standard-remuneration", tags=["standard_remuneration"])
class StandardRemunerationRow(BaseModel):
grade: str
monthly_amount: Decimal
salary_from: Decimal
salary_to: Optional[Decimal]
health_insurance_no_care: Decimal
health_insurance_with_care: Decimal
pension_insurance: Decimal
@router.get("/years")
async def get_years():
"""登録済みの年度一覧を取得"""
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("SELECT DISTINCT rate_year FROM standard_remuneration ORDER BY rate_year DESC")
rows = cur.fetchall()
years = [row["rate_year"] for row in rows]
return years
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("")
async def get_standard_remuneration(year: int, prefecture: Optional[str] = None):
"""指定された年度・都道府県の標準報酬月額表を取得"""
try:
with get_connection() as conn:
with conn.cursor() as cur:
if prefecture:
query = """
SELECT rate_year, prefecture, grade, monthly_amount, salary_from, salary_to,
health_insurance_no_care, health_insurance_with_care, pension_insurance
FROM standard_remuneration
WHERE rate_year = %s AND prefecture = %s
ORDER BY prefecture,
CASE WHEN grade ~ '^[0-9]+$' THEN CAST(grade AS INTEGER) ELSE 999 END,
grade
"""
cur.execute(query, (year, prefecture))
else:
query = """
SELECT rate_year, prefecture, grade, monthly_amount, salary_from, salary_to,
health_insurance_no_care, health_insurance_with_care, pension_insurance
FROM standard_remuneration
WHERE rate_year = %s
ORDER BY prefecture,
CASE WHEN grade ~ '^[0-9]+$' THEN CAST(grade AS INTEGER) ELSE 999 END,
grade
"""
cur.execute(query, (year,))
rows = cur.fetchall()
result = []
for row in rows:
result.append({
"rate_year": row["rate_year"],
"prefecture": row["prefecture"],
"grade": row["grade"],
"monthly_amount": row["monthly_amount"],
"salary_from": row["salary_from"],
"salary_to": row["salary_to"],
"health_insurance_no_care": row["health_insurance_no_care"],
"health_insurance_with_care": row["health_insurance_with_care"],
"pension_insurance": row["pension_insurance"]
})
return result
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/import")
async def import_standard_remuneration(
file: UploadFile = File(...),
prefecture: str = Form(...)
):
"""Excelファイルから標準報酬月額表をインポート年度はA1セルから自動取得"""
try:
# Excelファイルを読み込み
contents = await file.read()
wb = openpyxl.load_workbook(BytesIO(contents), data_only=True)
# 指定された都道府県のシートを取得
if prefecture not in wb.sheetnames:
raise HTTPException(
status_code=400,
detail=f"シート '{prefecture}' が見つかりません。利用可能なシート: {', '.join(wb.sheetnames)}"
)
ws = wb[prefecture]
# A1セルから年度を取得
# 例: "令和7年3月分4月納付分からの健康保険・厚生年金保険の保険料額表東京都"
a1_value = ws["A1"].value
if not a1_value:
raise HTTPException(status_code=400, detail="A1セルに年度情報がありません")
# 令和年号から西暦年度を計算(例: 令和7年 = 2025年度
import re
match = re.search(r'令和(\d+)年', str(a1_value))
if match:
reiwa_year = int(match.group(1))
rate_year = 2018 + reiwa_year # 令和元年 = 2019年
else:
# 西暦が直接記載されている場合
match = re.search(r'(\d{4})年', str(a1_value))
if match:
rate_year = int(match.group(1))
else:
raise HTTPException(
status_code=400,
detail=f"A1セルから年度を抽出できません: {a1_value}"
)
# データを抽出12行目から開始
data_rows = []
errors = []
# min_row=12で12行目から読み取りを開始
# enumerate()はイテレータの要素番号なので、start=0にして、実際の行番号は手動で計算
for idx, row in enumerate(ws.iter_rows(min_row=12, values_only=True)):
row_idx = 12 + idx # 実際のExcel行番号
# 等級が空の場合は終了
if not row[0]:
break
try:
grade = str(row[0]).strip()
# 各値を取得し、Noneや空白を処理
def clean_value(val, field_name=""):
if val is None or val == '':
return None
# 数値型の場合はそのまま返す
if isinstance(val, (int, float)):
return val
# 文字列の場合
if isinstance(val, str):
# 空白を除去
val = val.strip()
# 空、ハイフン、全角ダッシュなどをNoneに
if val in ('', '-', '', '', '', ''):
return None
# 「円」「未満」「以上」などの文字を除去
val = val.replace('', '').replace('未満', '').replace('以上', '')
# カンマを除去
val = val.replace(',', '').replace('', '')
# 全角数字を半角に変換
val = val.translate(str.maketrans('', '0123456789'))
# 再度空チェック
if val == '':
return None
return val
return val
# デバッグ用に元のデータを保存最初の7列を確認
raw_data = {
'col0_grade': row[0] if len(row) > 0 else None,
'col1': row[1] if len(row) > 1 else None,
'col2': row[2] if len(row) > 2 else None,
'col3': row[3] if len(row) > 3 else None,
'col4': row[4] if len(row) > 4 else None,
'col5': row[5] if len(row) > 5 else None,
'col6': row[6] if len(row) > 6 else None,
'col7': row[7] if len(row) > 7 else None,
}
# 報酬月額範囲の処理
# 複数のパターンに対応:
# パターンA: 列2に範囲"58,000円63,000円"
# パターンB: 列2=下限, 列3=「~」, 列4=上限
# パターンC: 列2=下限, 列3=上限
monthly_amount = clean_value(row[1], 'monthly_amount')
# 列3が「」かチェック
col3_raw = str(row[3]) if len(row) > 3 and row[3] is not None else ''
col3_cleaned = col3_raw.strip()
# 列2に範囲が含まれているかチェック
col2_raw = str(row[2]) if len(row) > 2 and row[2] is not None else ''
if col3_cleaned in ('', '', '-'):
# パターンB: 列2=下限, 列3=「~」, 列4=上限
salary_from = clean_value(row[2], 'salary_from')
salary_to = clean_value(row[4] if len(row) > 4 else None, 'salary_to')
# 保険料は列5以降
health_no_care = clean_value(row[5] if len(row) > 5 else None, 'health_no_care')
health_with_care = clean_value(row[6] if len(row) > 6 else None, 'health_with_care')
pension = clean_value(row[7] if len(row) > 7 else None, 'pension')
elif '' in col2_raw or '' in col2_raw:
# パターンA: 列2に範囲が1セルに入っている
range_text = col2_raw.replace('', '').replace('未満', '').replace('以上', '')
parts = range_text.replace('', '').split('')
if len(parts) >= 2:
salary_from = clean_value(parts[0].strip(), 'salary_from')
salary_to = clean_value(parts[1].strip(), 'salary_to')
else:
salary_from = clean_value(row[2], 'salary_from')
salary_to = None
# 保険料は列3以降
health_no_care = clean_value(row[3] if len(row) > 3 else None, 'health_no_care')
health_with_care = clean_value(row[4] if len(row) > 4 else None, 'health_with_care')
pension = clean_value(row[5] if len(row) > 5 else None, 'pension')
else:
# パターンC: 列2=下限、列3=上限の通常パターン
salary_from = clean_value(row[2], 'salary_from')
salary_to = clean_value(row[3] if len(row) > 3 else None, 'salary_to')
health_no_care = clean_value(row[4] if len(row) > 4 else None, 'health_no_care')
health_with_care = clean_value(row[5] if len(row) > 5 else None, 'health_with_care')
pension = clean_value(row[6] if len(row) > 6 else None, 'pension')
# 必須フィールドのチェック(詳細なエラーメッセージ)
missing_fields = []
if monthly_amount is None:
missing_fields.append(f"標準報酬月額(列2)={repr(raw_data['col1'])}")
if salary_from is None:
missing_fields.append(f"報酬月額下限={repr(raw_data['col2'])}")
if health_no_care is None:
missing_fields.append(f"健保(介護なし)={repr(raw_data.get('col4') or raw_data.get('col3'))}")
if health_with_care is None:
missing_fields.append(f"健保(介護あり)={repr(raw_data.get('col5') or raw_data.get('col4'))}")
if pension is None:
missing_fields.append(f"厚生年金={repr(raw_data.get('col6') or raw_data.get('col5'))}")
if missing_fields:
errors.append(f"{row_idx}: 必須フィールドが空: {', '.join(missing_fields)}")
continue
# Decimal変換より詳細なエラー処理
try:
monthly_amount = Decimal(str(monthly_amount))
except Exception as e:
errors.append(f"{row_idx}: 標準報酬月額の変換エラー: {repr(raw_data['col1'])} -> {e}")
continue
try:
salary_from = Decimal(str(salary_from))
except Exception as e:
errors.append(f"{row_idx}: 報酬月額下限の変換エラー: {repr(raw_data['col2'])} -> {e}")
continue
try:
salary_to = Decimal(str(salary_to)) if salary_to is not None else None
except Exception as e:
errors.append(f"{row_idx}: 報酬月額上限の変換エラー: 範囲から抽出または{repr(raw_data.get('col3'))} -> {e}")
continue
try:
health_no_care = Decimal(str(health_no_care))
except Exception as e:
errors.append(f"{row_idx}: 健保(介護なし)の変換エラー: {repr(health_no_care)} (元データ列={raw_data}) -> {e}")
continue
try:
health_with_care = Decimal(str(health_with_care))
except Exception as e:
errors.append(f"{row_idx}: 健保(介護あり)の変換エラー: {repr(health_with_care)} (元データ列={raw_data}) -> {e}")
continue
try:
pension = Decimal(str(pension))
except Exception as e:
errors.append(f"{row_idx}: 厚生年金の変換エラー: {repr(pension)} (元データ列={raw_data}) -> {e}")
continue
data_rows.append((
rate_year, prefecture, grade, monthly_amount, salary_from, salary_to,
health_no_care, health_with_care, pension
))
except Exception as e:
errors.append(f"{row_idx}: 予期しないエラー: {type(e).__name__}: {str(e)}")
continue
if not data_rows:
error_msg = "有効なデータが見つかりませんでした"
if errors:
error_msg += "\n\nエラー詳細:\n" + "\n".join(errors[:15])
raise HTTPException(status_code=400, detail=error_msg)
# データベースに保存
with get_connection() as conn:
with conn.cursor() as cur:
# 既存データを削除
cur.execute(
"DELETE FROM standard_remuneration WHERE rate_year = %s AND prefecture = %s",
(rate_year, prefecture)
)
# 新しいデータを挿入
insert_query = """
INSERT INTO standard_remuneration
(rate_year, prefecture, grade, monthly_amount, salary_from, salary_to,
health_insurance_no_care, health_insurance_with_care, pension_insurance)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
cur.executemany(insert_query, data_rows)
conn.commit()
imported_count = len(data_rows)
return {
"message": "インポートが完了しました",
"year": rate_year,
"prefecture": prefecture,
"imported_count": imported_count,
"source": a1_value
}
except ValueError as e:
raise HTTPException(status_code=400, detail="年度は数値で入力してください")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("")
async def delete_standard_remuneration(year: int, prefecture: str):
"""指定された年度・都道府県の標準報酬月額表を削除"""
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"DELETE FROM standard_remuneration WHERE rate_year = %s AND prefecture = %s",
(year, prefecture)
)
deleted_count = cur.rowcount
conn.commit()
return {
"message": "削除しました",
"year": year,
"prefecture": prefecture,
"deleted_count": deleted_count
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))