1032 lines
47 KiB
Python
1032 lines
47 KiB
Python
"""
|
||
給与設定・税率・保険料管理API (Settings Management API)
|
||
"""
|
||
from fastapi import APIRouter, HTTPException, status, UploadFile, File
|
||
from typing import List, Optional
|
||
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"])
|
||
|
||
|
||
# ========================================
|
||
# ヘルパー関数:年度フォールバック処理
|
||
# ========================================
|
||
def get_child_support_rate_with_fallback(target_year: int) -> Optional[dict]:
|
||
"""子ども・子育て拠出金率を取得(指定年度がない場合は前年度を参照、最大3年遡る)"""
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
# まず指定年度を検索
|
||
cur.execute(
|
||
"SELECT * FROM child_support_contribution_rates WHERE rate_year = %s ORDER BY contribution_id DESC LIMIT 1",
|
||
(target_year,)
|
||
)
|
||
result = cur.fetchone()
|
||
|
||
# 見つからない場合は前年度以前を検索
|
||
if not result:
|
||
for year_offset in range(1, 4):
|
||
previous_year = target_year - year_offset
|
||
cur.execute(
|
||
"SELECT * FROM child_support_contribution_rates WHERE rate_year = %s ORDER BY contribution_id DESC LIMIT 1",
|
||
(previous_year,)
|
||
)
|
||
result = cur.fetchone()
|
||
if result:
|
||
break
|
||
|
||
return result
|
||
|
||
|
||
# ========================================
|
||
# 給与基本設定
|
||
# ========================================
|
||
|
||
|
||
@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="従業員が見つかりません")
|
||
|
||
# employment_insurance_eligible カラムが存在するか確認
|
||
cur.execute(
|
||
"SELECT 1 FROM information_schema.columns WHERE table_name='salary_settings' AND column_name='employment_insurance_eligible'"
|
||
)
|
||
has_employment_insurance_column = cur.fetchone()
|
||
|
||
if has_employment_insurance_column:
|
||
# カラムが存在する場合
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO salary_settings (
|
||
employee_id, base_salary, hourly_rate, employment_type,
|
||
payment_type, commute_allowance, other_allowance,
|
||
employment_insurance_eligible,
|
||
valid_from, valid_to
|
||
) VALUES (%s, %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.employment_insurance_eligible,
|
||
setting.valid_from, setting.valid_to
|
||
),
|
||
)
|
||
else:
|
||
# カラムが存在しない場合(互換性維持)
|
||
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
|
||
),
|
||
)
|
||
print("[DEBUG] employment_insurance_eligible カラムがまだ存在しないため、除外しました")
|
||
|
||
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="更新するデータがありません")
|
||
|
||
# employment_insurance_eligible はまだデータベースに存在しない可能性があるため、除外
|
||
if 'employment_insurance_eligible' in update_data:
|
||
try:
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"SELECT 1 FROM information_schema.columns WHERE table_name='salary_settings' AND column_name='employment_insurance_eligible'"
|
||
)
|
||
column_exists = cur.fetchone()
|
||
if not column_exists:
|
||
update_data.pop('employment_insurance_eligible')
|
||
print("[DEBUG] employment_insurance_eligible カラムがまだ存在しないため、除外しました")
|
||
except Exception as e:
|
||
print(f"[DEBUG] カラム確認エラー: {e}")
|
||
update_data.pop('employment_insurance_eligible', None)
|
||
|
||
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
|
||
|
||
# B列(col 1)とC列(col 2)から月収範囲を読み込む
|
||
# A列は順序番号なので、B列から開始
|
||
for col_idx in range(1, 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}件インポートしました", "year": extracted_year}
|
||
|
||
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
|
||
|
||
# B列(col 1)とC列(col 2)から月収範囲を読み込む
|
||
# A列は順序番号なので、B列から開始
|
||
for i in range(1, 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}件インポートしました", "year": extracted_year}
|
||
|
||
# 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/years")
|
||
def get_income_tax_years():
|
||
"""所得税率表の年度一覧を取得"""
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
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:
|
||
# 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()
|
||
|
||
|
||
@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:
|
||
# 月収がmonthly_income_from <= income < monthly_income_toの範囲に入るか検索
|
||
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()
|
||
|
||
# 見つからない場合は、Toが同値のケースも検索
|
||
if not result:
|
||
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.delete("/income-tax/year/{year}")
|
||
def delete_income_tax_by_year(year: int):
|
||
"""指定された年度の所得税率表データをすべて削除"""
|
||
try:
|
||
with get_connection() as conn:
|
||
with conn.cursor() as cur:
|
||
cur.execute(
|
||
"DELETE FROM income_tax_table WHERE tax_year = %s",
|
||
(year,)
|
||
)
|
||
deleted_count = cur.rowcount
|
||
conn.commit()
|
||
|
||
return {
|
||
"message": f"{year}年度のデータを削除しました",
|
||
"year": year,
|
||
"deleted_count": deleted_count
|
||
}
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# ========================================
|
||
# 社会保険料上限設定管理
|
||
# ========================================
|
||
|
||
@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()
|
||
|