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

36
backend/add_my_number.py Normal file
View File

@@ -0,0 +1,36 @@
import psycopg
from psycopg.rows import dict_row
# データベース接続
conn = psycopg.connect(
host="192.168.0.61",
port=55432,
dbname="njts_acct",
user="njts_app",
password="njts_app2025",
row_factory=dict_row
)
# SQLファイルを読み込んで実行
with open('sql/add_my_number_field.sql', encoding='utf-8') as f:
sql = f.read()
cur = conn.cursor()
cur.execute(sql)
conn.commit()
print("マイナンバーフィールドの追加が完了しました")
# 確認
cur.execute("""
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'employees' AND column_name = 'my_number'
""")
result = cur.fetchone()
if result:
print(f"✓ フィールド追加確認: {result}")
else:
print("✗ フィールドが見つかりません")
conn.close()

View File

@@ -81,6 +81,9 @@ app.include_router(year_locks.router)
from app.routers import cash
app.include_router(cash.router)
from app.routers import file_upload
app.include_router(file_upload.router)
# ─────────────────────────
# 給与モジュール (Payroll Module)
# ─────────────────────────
@@ -90,6 +93,9 @@ 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.settings.standard_remuneration_router import router as standard_remuneration_router
app.include_router(standard_remuneration_router)
from app.payroll.calculation.router import router as payroll_calculation_router
app.include_router(payroll_calculation_router)
@@ -99,6 +105,16 @@ app.include_router(payroll_bonus_router)
from fastapi.staticfiles import StaticFiles
import os
from pathlib import Path
# アップロードファイル用の静的ファイル配信
uploads_dir = Path("uploads")
uploads_dir.mkdir(exist_ok=True)
app.mount(
"/uploads",
StaticFiles(directory="uploads"),
name="uploads",
)
# 静的ファイル配信(コメントアウト - ディレクトリが存在しない)
# app.mount(

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,14 +611,54 @@ 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/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):
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()
with get_connection() as conn:
with conn.cursor() as cur:
if dependents_count is not None:
cur.execute(
"""

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

View File

@@ -0,0 +1,173 @@
"""
ファイルアップロード管理
"""
from fastapi import APIRouter, UploadFile, File, HTTPException
from pathlib import Path
import shutil
import uuid
from datetime import datetime
router = APIRouter(prefix="/files", tags=["File Upload"])
# アップロード先のベースディレクトリ
UPLOAD_BASE_DIR = Path("uploads")
UPLOAD_BASE_DIR.mkdir(exist_ok=True)
# 許可されるファイルタイプ
ALLOWED_EXTENSIONS = {
'image': {'.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'},
'document': {'.pdf', '.doc', '.docx', '.xls', '.xlsx', '.txt'},
}
def get_file_extension(filename: str) -> str:
"""ファイル拡張子を取得"""
return Path(filename).suffix.lower()
def is_allowed_file(filename: str, file_type: str = None) -> bool:
"""許可されたファイルタイプか確認"""
ext = get_file_extension(filename)
if file_type == 'image':
return ext in ALLOWED_EXTENSIONS['image']
elif file_type == 'document':
return ext in ALLOWED_EXTENSIONS['document']
else:
# すべての許可された拡張子
all_extensions = set()
for exts in ALLOWED_EXTENSIONS.values():
all_extensions.update(exts)
return ext in all_extensions
def generate_unique_filename(original_filename: str, prefix: str = "") -> str:
"""一意のファイル名を生成"""
ext = get_file_extension(original_filename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
unique_id = str(uuid.uuid4())[:8]
if prefix:
return f"{prefix}_{timestamp}_{unique_id}{ext}"
return f"{timestamp}_{unique_id}{ext}"
@router.post("/upload/employee/{employee_code}")
async def upload_employee_file(
employee_code: str,
file: UploadFile = File(...),
file_type: str = "image"
):
"""
従業員関連ファイルをアップロード
Args:
employee_code: 従業員コード
file: アップロードするファイル
file_type: ファイルタイプ (image, document)
Returns:
アップロードされたファイルのパス
"""
# ファイルタイプチェック
if not is_allowed_file(file.filename, file_type):
raise HTTPException(
status_code=400,
detail=f"許可されていないファイル形式です。{file_type}タイプの許可される拡張子: {ALLOWED_EXTENSIONS.get(file_type, 'なし')}"
)
# 保存先ディレクトリ作成
employee_dir = UPLOAD_BASE_DIR / "employees" / employee_code
employee_dir.mkdir(parents=True, exist_ok=True)
# 一意のファイル名生成
unique_filename = generate_unique_filename(file.filename, employee_code)
file_path = employee_dir / unique_filename
# ファイル保存
try:
with file_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"ファイル保存に失敗しました: {str(e)}")
# 相対パスを返す(データベースに保存する値)
relative_path = f"/uploads/employees/{employee_code}/{unique_filename}"
return {
"success": True,
"file_path": relative_path,
"filename": unique_filename,
"original_filename": file.filename,
"size": file_path.stat().st_size
}
@router.post("/upload/general")
async def upload_general_file(
file: UploadFile = File(...),
category: str = "general"
):
"""
一般ファイルをアップロード
Args:
file: アップロードするファイル
category: カテゴリー (general, reports, temp など)
Returns:
アップロードされたファイルのパス
"""
# ファイルタイプチェック
if not is_allowed_file(file.filename):
raise HTTPException(
status_code=400,
detail=f"許可されていないファイル形式です"
)
# 保存先ディレクトリ作成
category_dir = UPLOAD_BASE_DIR / category
category_dir.mkdir(parents=True, exist_ok=True)
# 一意のファイル名生成
unique_filename = generate_unique_filename(file.filename)
file_path = category_dir / unique_filename
# ファイル保存
try:
with file_path.open("wb") as buffer:
shutil.copyfileobj(file.file, buffer)
except Exception as e:
raise HTTPException(status_code=500, detail=f"ファイル保存に失敗しました: {str(e)}")
# 相対パスを返す
relative_path = f"/uploads/{category}/{unique_filename}"
return {
"success": True,
"file_path": relative_path,
"filename": unique_filename,
"original_filename": file.filename,
"size": file_path.stat().st_size
}
@router.delete("/delete")
async def delete_file(file_path: str):
"""
ファイルを削除
Args:
file_path: 削除するファイルのパス (/uploads/... 形式)
"""
# セキュリティ: パストラバーサル攻撃を防ぐ
if ".." in file_path or file_path.startswith("/"):
file_path = file_path.lstrip("/")
full_path = Path(file_path)
# uploadsディレクトリ内のファイルのみ削除可能
if not str(full_path).startswith("uploads"):
raise HTTPException(status_code=400, detail="無効なファイルパスです")
if not full_path.exists():
raise HTTPException(status_code=404, detail="ファイルが見つかりません")
try:
full_path.unlink()
return {"success": True, "message": "ファイルを削除しました"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"ファイル削除に失敗しました: {str(e)}")

View File

@@ -0,0 +1,25 @@
import psycopg
conn = psycopg.connect(
host='192.168.0.61',
port=55432,
dbname='njts_acct',
user='njts_app',
password='njts_app2025'
)
cur = conn.cursor()
cur.execute("""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'insurance_rates'
ORDER BY ordinal_position
""")
rows = cur.fetchall()
print('insurance_ratesテーブルのカラム:')
for r in rows:
print(f' {r[0]}: {r[1]}')
cur.close()
conn.close()

View File

@@ -0,0 +1,33 @@
import psycopg
conn = psycopg.connect(
host='192.168.0.61',
port=55432,
dbname='njts_acct',
user='njts_app',
password='njts_app2025'
)
cur = conn.cursor()
cur.execute("""
SELECT employee_id, employee_code, name,
photo_url IS NOT NULL as has_photo,
CASE WHEN photo_url IS NOT NULL THEN LENGTH(photo_url) ELSE 0 END as photo_length,
CASE WHEN photo_url IS NOT NULL THEN SUBSTRING(photo_url, 1, 50) ELSE NULL END as photo_preview
FROM employees
WHERE employee_code IN ('20190001', '20190002')
ORDER BY employee_code
""")
rows = cur.fetchall()
print('従業員の写真データ状況:')
print('-' * 80)
for r in rows:
print(f'ID: {r[0]}, Code: {r[1]}, Name: {r[2]}')
print(f' Has Photo: {r[3]}, Photo Length: {r[4]}')
if r[5]:
print(f' Photo Preview: {r[5]}...')
print()
cur.close()
conn.close()

View File

@@ -0,0 +1,51 @@
import sys
sys.path.insert(0, r'c:\workspace\njts-accounting-core\backend')
from app.core.database import get_connection
try:
with get_connection() as conn:
with conn.cursor() as cur:
# テーブルの存在確認
cur.execute("""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = 'standard_remuneration'
)
""")
exists = cur.fetchone()["exists"]
print(f"テーブル存在: {exists}")
if exists:
# データ件数確認
cur.execute("SELECT COUNT(*) as count FROM standard_remuneration")
count = cur.fetchone()["count"]
print(f"データ件数: {count}")
# 年度リスト取得
cur.execute("SELECT DISTINCT rate_year FROM standard_remuneration ORDER BY rate_year DESC")
years = cur.fetchall()
print(f"年度リスト: {years}")
else:
print("テーブルが存在しません。作成します...")
cur.execute("""
CREATE TABLE IF NOT EXISTS standard_remuneration (
id SERIAL PRIMARY KEY,
rate_year INTEGER NOT NULL,
prefecture VARCHAR(50) NOT NULL,
grade VARCHAR(20) NOT NULL,
monthly_amount DECIMAL(12,2) NOT NULL,
salary_from DECIMAL(12,2) NOT NULL,
salary_to DECIMAL(12,2),
health_insurance_no_care DECIMAL(12,2) NOT NULL,
health_insurance_with_care DECIMAL(12,2) NOT NULL,
pension_insurance DECIMAL(12,2) NOT NULL,
UNIQUE(rate_year, prefecture, grade)
)
""")
conn.commit()
print("テーブルを作成しました")
except Exception as e:
print(f"エラー: {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,53 @@
import psycopg2
import os
from dotenv import load_dotenv
load_dotenv()
DB_CONFIG = {
"host": os.getenv("DB_HOST", "192.168.0.61"),
"port": os.getenv("DB_PORT", "55432"),
"database": os.getenv("DB_NAME", "njts_acct"),
"user": os.getenv("DB_USER", "njts_app"),
"password": os.getenv("DB_PASSWORD", "njts_app2025"),
}
def create_standard_remuneration_table():
"""標準報酬月額表テーブルを作成"""
conn = psycopg2.connect(**DB_CONFIG)
cur = conn.cursor()
# テーブル作成
sql = """
CREATE TABLE IF NOT EXISTS standard_remuneration (
id SERIAL PRIMARY KEY,
rate_year INTEGER NOT NULL,
prefecture VARCHAR(50) NOT NULL,
grade VARCHAR(20) NOT NULL,
monthly_amount DECIMAL(12, 2) NOT NULL,
salary_from DECIMAL(12, 2) NOT NULL,
salary_to DECIMAL(12, 2),
health_insurance_no_care DECIMAL(12, 2) NOT NULL,
health_insurance_with_care DECIMAL(12, 2) NOT NULL,
pension_insurance DECIMAL(12, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(rate_year, prefecture, grade)
);
-- インデックス作成
CREATE INDEX IF NOT EXISTS idx_standard_remuneration_year_pref
ON standard_remuneration(rate_year, prefecture);
CREATE INDEX IF NOT EXISTS idx_standard_remuneration_salary
ON standard_remuneration(rate_year, prefecture, salary_from, salary_to);
"""
cur.execute(sql)
conn.commit()
print("✅ standard_remuneration テーブルを作成しました")
cur.close()
conn.close()
if __name__ == "__main__":
create_standard_remuneration_table()

View File

@@ -0,0 +1,58 @@
import os
import sys
# 環境変数を設定
os.environ["DB_HOST"] = "192.168.0.61"
os.environ["DB_PORT"] = "55432"
os.environ["DB_NAME"] = "njts_acct"
os.environ["DB_USER"] = "njts_app"
os.environ["DB_PASSWORD"] = "njts_app2025"
sys.path.insert(0, r'c:\workspace\njts-accounting-core\backend')
from app.core.database import get_connection
try:
with get_connection() as conn:
with conn.cursor() as cur:
print("standard_remunerationテーブルを作成中...")
cur.execute("""
CREATE TABLE IF NOT EXISTS standard_remuneration (
id SERIAL PRIMARY KEY,
rate_year INTEGER NOT NULL,
prefecture VARCHAR(50) NOT NULL,
grade VARCHAR(20) NOT NULL,
monthly_amount DECIMAL(12,2) NOT NULL,
salary_from DECIMAL(12,2) NOT NULL,
salary_to DECIMAL(12,2),
health_insurance_no_care DECIMAL(12,2) NOT NULL,
health_insurance_with_care DECIMAL(12,2) NOT NULL,
pension_insurance DECIMAL(12,2) NOT NULL,
UNIQUE(rate_year, prefecture, grade)
)
""")
# インデックスを作成
cur.execute("""
CREATE INDEX IF NOT EXISTS idx_std_rem_year_pref
ON standard_remuneration(rate_year, prefecture)
""")
conn.commit()
print("✓ テーブルとインデックスを作成しました")
# テーブルの確認
cur.execute("""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'standard_remuneration'
ORDER BY ordinal_position
""")
columns = cur.fetchall()
print("\n作成されたカラム:")
for col in columns:
print(f" - {col['column_name']}: {col['data_type']}")
except Exception as e:
print(f"エラー: {e}")
import traceback
traceback.print_exc()

View File

@@ -0,0 +1,128 @@
"""
介護保険計算のデバッグスクリプト
"""
from datetime import date
from app.core.database import get_connection
# 従業員20190001の情報を確認
employee_code = "20190001"
payment_date = date(2025, 12, 31)
print("=" * 60)
print(f"従業員コード: {employee_code}")
print(f"支給日: {payment_date}")
print("=" * 60)
with get_connection() as conn:
with conn.cursor() as cur:
# 従業員情報を取得
cur.execute(
"""
SELECT employee_id, employee_code, name, birth_date
FROM employees
WHERE employee_code = %s
""",
(employee_code,)
)
employee = cur.fetchone()
if not employee:
print("従業員が見つかりません")
exit(1)
print(f"\n【従業員情報】")
print(f" ID: {employee['employee_id']}")
print(f" コード: {employee['employee_code']}")
print(f" 氏名: {employee['name']}")
print(f" 生年月日: {employee['birth_date']}")
# 年齢を計算
if employee['birth_date']:
birth_date = employee['birth_date']
age = payment_date.year - birth_date.year
if (payment_date.month, payment_date.day) < (birth_date.month, birth_date.day):
age -= 1
print(f" 年齢({payment_date}時点): {age}")
print(f" 介護保険対象: {'はい (40-64歳)' if 40 <= age < 65 else 'いいえ'}")
else:
print(f" 生年月日が未設定です")
age = 0
# 介護保険料率を確認2025年
print(f"\n【介護保険料率 - 2025年】")
cur.execute(
"""
SELECT * FROM insurance_rates
WHERE rate_type = '介護保険'
AND rate_year = 2025
ORDER BY rate_id
"""
)
rates_2025 = cur.fetchall()
if rates_2025:
for rate in rates_2025:
print(f" ID: {rate['rate_id']}")
print(f" 計算対象: {rate['calculation_target']}")
print(f" 都道府県: {rate['prefecture']}")
print(f" 従業員負担率: {float(rate['employee_rate']) * 100:.3f}%")
print(f" 会社負担率: {float(rate['employer_rate']) * 100:.3f}%")
print(f" 年齢下限: {rate['care_insurance_age_threshold']}歳以上")
print(f" 備考: {rate['notes']}")
print()
else:
print(" ⚠️ 2025年の介護保険料率が見つかりません")
# 介護保険料率を確認2026年
print(f"【介護保険料率 - 2026年】")
cur.execute(
"""
SELECT * FROM insurance_rates
WHERE rate_type = '介護保険'
AND rate_year = 2026
ORDER BY rate_id
"""
)
rates_2026 = cur.fetchall()
if rates_2026:
for rate in rates_2026:
print(f" ID: {rate['rate_id']}")
print(f" 計算対象: {rate['calculation_target']}")
print(f" 都道府県: {rate['prefecture']}")
print(f" 従業員負担率: {float(rate['employee_rate']) * 100:.3f}%")
print(f" 会社負担率: {float(rate['employer_rate']) * 100:.3f}%")
print(f" 年齢下限: {rate['care_insurance_age_threshold']}歳以上")
print()
else:
print(" 2026年の介護保険料率が見つかりません")
# 2025年12月の給与計算データを確認
print(f"\n【2025年12月の給与計算】")
cur.execute(
"""
SELECT * FROM monthly_payroll
WHERE employee_id = %s
AND payroll_year = 2025
AND payroll_month = 12
ORDER BY payroll_id DESC
LIMIT 1
""",
(employee['employee_id'],)
)
payroll = cur.fetchone()
if payroll:
print(f" Payroll ID: {payroll['payroll_id']}")
print(f" 基本給: ¥{float(payroll['base_salary']):,.0f}")
print(f" 健康保険: ¥{float(payroll['health_insurance']):,.0f}")
print(f" 介護保険: ¥{float(payroll['care_insurance']):,.0f}")
print(f" 厚生年金: ¥{float(payroll['pension_insurance']):,.0f}")
print(f" 雇用保険: ¥{float(payroll['employment_insurance']):,.0f}")
print(f" 計算日: {payroll['created_at']}")
else:
print(" 給与計算データが見つかりません")
print("\n" + "=" * 60)
print("診断完了")
print("=" * 60)

View File

@@ -0,0 +1,42 @@
import psycopg
conn = psycopg.connect(
host='192.168.0.61',
port=55432,
dbname='njts_acct',
user='njts_app',
password='njts_app2025'
)
cur = conn.cursor()
# ALTER TABLE実行
cur.execute("""
ALTER TABLE employees
ALTER COLUMN photo_url TYPE TEXT,
ALTER COLUMN my_number_card_image_url TYPE TEXT,
ALTER COLUMN residence_card_image_url TYPE TEXT,
ALTER COLUMN health_insurance_card_image_url TYPE TEXT,
ALTER COLUMN other_id_image_url TYPE TEXT
""")
conn.commit()
print('写真カラムのデータ型をTEXTに変更しました')
# 確認
cur.execute("""
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'employees'
AND column_name LIKE '%_url'
ORDER BY column_name
""")
result = cur.fetchall()
print('\nカラム情報:')
for r in result:
length = r[2] if r[2] else '制限なし'
print(f' {r[0]}: {r[1]} ({length})')
cur.close()
conn.close()

View File

@@ -0,0 +1,15 @@
-- 従業員の写真・身分証明書画像フィールドを追加
-- Add employee photo and ID document image fields
ALTER TABLE employees
ADD COLUMN IF NOT EXISTS photo_url VARCHAR(500),
ADD COLUMN IF NOT EXISTS my_number_card_image_url VARCHAR(500),
ADD COLUMN IF NOT EXISTS residence_card_image_url VARCHAR(500),
ADD COLUMN IF NOT EXISTS health_insurance_card_image_url VARCHAR(500),
ADD COLUMN IF NOT EXISTS other_id_image_url VARCHAR(500);
COMMENT ON COLUMN employees.photo_url IS '本人写真のURL';
COMMENT ON COLUMN employees.my_number_card_image_url IS 'マイナンバーカード画像のURL';
COMMENT ON COLUMN employees.residence_card_image_url IS '在留カード画像のURL';
COMMENT ON COLUMN employees.health_insurance_card_image_url IS '健康保険証画像のURL';
COMMENT ON COLUMN employees.other_id_image_url IS 'その他の身分証明書画像のURL';

View File

@@ -0,0 +1,12 @@
-- マイナンバーカード番号フィールドを追加
-- Add my_number field to employees table
ALTER TABLE employees
ADD COLUMN IF NOT EXISTS my_number VARCHAR(12);
COMMENT ON COLUMN employees.my_number IS 'マイナンバー個人番号12桁';
-- マイナンバーは機密情報なので、ユニーク制約を追加
CREATE UNIQUE INDEX IF NOT EXISTS idx_employees_my_number
ON employees(my_number)
WHERE my_number IS NOT NULL;

View File

@@ -0,0 +1,32 @@
-- 標準報酬月額表テーブル
CREATE TABLE IF NOT EXISTS standard_remuneration (
id SERIAL PRIMARY KEY,
rate_year INTEGER NOT NULL,
prefecture VARCHAR(50) NOT NULL,
grade VARCHAR(20) NOT NULL,
monthly_amount DECIMAL(12, 2) NOT NULL,
salary_from DECIMAL(12, 2) NOT NULL,
salary_to DECIMAL(12, 2),
health_insurance_no_care DECIMAL(12, 2) NOT NULL,
health_insurance_with_care DECIMAL(12, 2) NOT NULL,
pension_insurance DECIMAL(12, 2) NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(rate_year, prefecture, grade)
);
-- インデックス作成
CREATE INDEX IF NOT EXISTS idx_standard_remuneration_year_pref
ON standard_remuneration(rate_year, prefecture);
CREATE INDEX IF NOT EXISTS idx_standard_remuneration_salary
ON standard_remuneration(rate_year, prefecture, salary_from, salary_to);
COMMENT ON TABLE standard_remuneration IS '健康保険・厚生年金保険の標準報酬月額表';
COMMENT ON COLUMN standard_remuneration.rate_year IS '適用年度';
COMMENT ON COLUMN standard_remuneration.prefecture IS '都道府県名';
COMMENT ON COLUMN standard_remuneration.grade IS '等級';
COMMENT ON COLUMN standard_remuneration.monthly_amount IS '標準報酬月額';
COMMENT ON COLUMN standard_remuneration.salary_from IS '報酬月額(下限)';
COMMENT ON COLUMN standard_remuneration.salary_to IS '報酬月額上限、最高等級の場合はNULL';
COMMENT ON COLUMN standard_remuneration.health_insurance_no_care IS '健康保険料(介護保険なし)';
COMMENT ON COLUMN standard_remuneration.health_insurance_with_care IS '健康保険料(介護保険あり)';
COMMENT ON COLUMN standard_remuneration.pension_insurance IS '厚生年金保険料';

View File

@@ -0,0 +1,15 @@
-- 写真フィールドのデータ型をVARCHAR(500)からTEXTに変更
-- Base64エンコードされた画像データは数万文字になるため
ALTER TABLE employees
ALTER COLUMN photo_url TYPE TEXT,
ALTER COLUMN my_number_card_image_url TYPE TEXT,
ALTER COLUMN residence_card_image_url TYPE TEXT,
ALTER COLUMN health_insurance_card_image_url TYPE TEXT,
ALTER COLUMN other_id_image_url TYPE TEXT;
-- 確認
SELECT column_name, data_type, character_maximum_length
FROM information_schema.columns
WHERE table_name = 'employees'
AND column_name LIKE '%_url';

View File

@@ -0,0 +1,34 @@
-- 2025年度の保険料率を追加
-- 健康保険2025年度 東京都)
INSERT INTO insurance_rates (rate_year, rate_type, calculation_target, prefecture, employee_rate, employer_rate, care_insurance_age_threshold, notes) VALUES
(2025, '健康保険', '給与', '東京都', 0.04985, 0.04985, NULL, '健康保険料率 9.97% (労使折半) - 給与'),
(2025, '健康保険', '賞与', '東京都', 0.04985, 0.04985, NULL, '健康保険料率 9.97% (労使折半) - 賞与')
ON CONFLICT DO NOTHING;
-- 介護保険2025年度
INSERT INTO insurance_rates (rate_year, rate_type, calculation_target, prefecture, employee_rate, employer_rate, care_insurance_age_threshold, notes) VALUES
(2025, '介護保険', '給与', '東京都', 0.0091, 0.0091, 40, '介護保険料率 1.82% (労使折半、40歳以上対象) - 給与'),
(2025, '介護保険', '賞与', '東京都', 0.0091, 0.0091, 40, '介護保険料率 1.82% (労使折半、40歳以上対象) - 賞与')
ON CONFLICT DO NOTHING;
-- 厚生年金2025年度
INSERT INTO insurance_rates (rate_year, rate_type, calculation_target, prefecture, employee_rate, employer_rate, care_insurance_age_threshold, notes) VALUES
(2025, '厚生年金', '給与', '東京都', 0.09150, 0.09150, NULL, '厚生年金保険料率 18.3% (労使折半) - 給与'),
(2025, '厚生年金', '賞与', '東京都', 0.09150, 0.09150, NULL, '厚生年金保険料率 18.3% (労使折半) - 賞与')
ON CONFLICT DO NOTHING;
-- 雇用保険2025年度
INSERT INTO insurance_rates (rate_year, rate_type, calculation_target, prefecture, employee_rate, employer_rate, care_insurance_age_threshold, notes) VALUES
(2025, '雇用保険', '給与', '東京都', 0.006, 0.009, NULL, '雇用保険料率 従業員0.6% 会社0.9% - 給与'),
(2025, '雇用保険', '賞与', '東京都', 0.006, 0.009, NULL, '雇用保険料率 従業員0.6% 会社0.9% - 賞与')
ON CONFLICT DO NOTHING;
-- 確認
SELECT rate_year, rate_type, calculation_target,
ROUND(employee_rate * 100, 3) as employee_pct,
ROUND(employer_rate * 100, 3) as employer_pct,
care_insurance_age_threshold
FROM insurance_rates
WHERE rate_year = 2025
ORDER BY rate_type, calculation_target;

View File

@@ -0,0 +1,94 @@
"""介護保険デバッグスクリプト"""
import psycopg
from datetime import date
from decimal import Decimal
# 接続
conn = psycopg.connect('host=192.168.0.61 port=55432 dbname=njts_acct user=njts_app password=njts_app2025')
cur = conn.cursor()
# 従業員情報取得
cur.execute("SELECT employee_id, employee_code, name, birth_date FROM employees WHERE employee_code = '20190001'")
emp = cur.fetchone()
print(f'従業員: ID={emp[0]}, コード={emp[1]}, 名前={emp[2]}, 生年月日={emp[3]}')
# 年齢計算
birth_date = emp[3]
payment_date = date(2025, 12, 25)
age = payment_date.year - birth_date.year
if (payment_date.month, payment_date.day) < (birth_date.month, birth_date.day):
age -= 1
print(f'\n支給日: {payment_date}')
print(f'年齢: {age}')
print(f'介護保険対象: {40 <= age < 65}')
# 2025年介護保険料率取得
print('\n=== 介護保険料率検索 ===')
cur.execute("""
SELECT rate_id, rate_year, rate_type, calculation_target, employee_rate, employer_rate, prefecture
FROM insurance_rates
WHERE rate_type = '介護保険'
AND rate_year = 2025
AND calculation_target = '給与'
ORDER BY rate_id DESC
LIMIT 1
""")
rate = cur.fetchone()
if rate:
print(f'料率ID: {rate[0]}')
print(f'年度: {rate[1]}')
print(f'種類: {rate[2]}')
print(f'計算対象: {rate[3]}')
print(f'従業員負担率: {rate[4]} ({float(rate[4])*100:.3f}%)')
print(f'会社負担率: {rate[5]} ({float(rate[5])*100:.3f}%)')
print(f'都道府県: {rate[6]}')
else:
print('料率が見つかりません!')
# 給与設定取得
print('\n=== 給与設定 ===')
cur.execute("""
SELECT base_salary 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
""", (emp[0], payment_date, payment_date))
salary = cur.fetchone()
if salary:
print(f'基本給: ¥{salary[0]:,}')
else:
print('給与設定が見つかりません!')
# 計算
if rate and salary:
print('\n=== 介護保険計算 ===')
base = Decimal(str(salary[0]))
rate_val = Decimal(str(rate[4])) # employee_rate
care_ins = (base * rate_val).quantize(Decimal('1'))
print(f'計算式: ¥{base:,} × {rate_val} = ¥{care_ins:,}')
# 実際の給与データ確認
print('\n=== 実際の給与データ ===')
cur.execute("""
SELECT payroll_id, payment_date, base_salary, health_insurance, care_insurance, status
FROM monthly_payroll
WHERE employee_id = %s
AND EXTRACT(YEAR FROM payment_date) = 2025
AND EXTRACT(MONTH FROM payment_date) = 12
ORDER BY payroll_id DESC
LIMIT 1
""", (emp[0],))
payroll = cur.fetchone()
if payroll:
print(f'給与ID: {payroll[0]}')
print(f'支給日: {payroll[1]}')
print(f'基本給: ¥{payroll[2]:,}')
print(f'健康保険: ¥{payroll[3]:,}')
print(f'介護保険: ¥{payroll[4]:,}')
print(f'ステータス: {payroll[5]}')
else:
print('2025年12月の給与データが見つかりません!')
conn.close()

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

View File

@@ -10,9 +10,9 @@
<label>
期間:
<input type="date" id="from" />
<input type="date" id="from" min="1900-01-01" max="2099-12-31" />
<input type="date" id="to" />
<input type="date" id="to" min="1900-01-01" max="2099-12-31" />
</label>
<button onclick="loadTrialBalance()">表示</button>

View File

@@ -44,7 +44,7 @@
<h2>修正仕訳入力</h2>
<label>仕訳日</label>
<input type="date" id="entryDate" min="1900-01-01" max="9999-12-31" />
<input type="date" id="entryDate" min="1900-01-01" max="2099-12-31" />
<label>摘要</label>
<input type="text" id="description" style="width: 60%" />

View File

@@ -122,7 +122,7 @@
type="date"
id="entryDate"
min="1900-01-01"
max="2999-12-31"
max="2099-12-31"
onchange="onEntryDateChanged()"
/>
</div>
@@ -264,10 +264,10 @@
type="date"
id="searchFromDate"
min="1900-01-01"
max="2999-12-31"
max="2099-12-31"
/>
<input type="date" id="searchToDate" min="1900-01-01" max="2999-12-31" />
<input type="date" id="searchToDate" min="1900-01-01" max="2099-12-31" />
<label style="margin-left: 16px">摘要</label>
<input type="text" id="searchKeyword" style="min-width: 600px" />

View File

@@ -27,8 +27,8 @@
<h2>仕訳一覧</h2>
<label>期間</label>
<input type="date" id="fromDate" min="1900-01-01" max="2999-12-31" />
<input type="date" id="toDate" min="1900-01-01" max="2999-12-31" />
<input type="date" id="fromDate" min="1900-01-01" max="2099-12-31" />
<input type="date" id="toDate" min="1900-01-01" max="2099-12-31" />
<label>摘要</label>
<input type="text" id="keyword" />

View File

@@ -10,7 +10,7 @@
<label>
日付:
<input type="date" id="journal-date" />
<input type="date" id="journal-date" min="1900-01-01" max="2099-12-31" />
</label>
<br /><br />

View File

@@ -78,9 +78,9 @@
<select id="accountSelect"></select>
<label>期間:</label>
<input type="date" id="fromDate" />
<input type="date" id="fromDate" min="1900-01-01" max="2099-12-31" />
<input type="date" id="toDate" />
<input type="date" id="toDate" min="1900-01-01" max="2099-12-31" />
<button onclick="reloadTransactions()">表示</button>
</div>

View File

@@ -108,10 +108,22 @@
font-size: 1.2em;
color: #007bff;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<a href="payroll.html" class="back-link">← 給与管理メニューに戻る</a>
<h1>給与管理 - 月次給与計算</h1>
<div class="filters">
@@ -188,7 +200,13 @@
</div>
<div class="form-group">
<label>支給日 *</label>
<input type="date" name="payment_date" required />
<input
type="date"
name="payment_date"
required
min="1900-01-01"
max="2099-12-31"
/>
</div>
</div>
@@ -292,7 +310,7 @@
</div>
<script>
const API_BASE = "http://localhost:8000";
const API_BASE = "http://localhost:18080";
async function loadEmployees() {
const response = await fetch(
@@ -436,19 +454,59 @@
);
const payroll = await response.json();
// 従業員情報と扶養家族情報を取得
const empResponse = await fetch(
`${API_BASE}/payroll/employees/${payroll.employee_id}`
);
const employee = await empResponse.json();
// 源泉税控除対象人数を計算19歳以上のみ
const dependentCount = employee.dependents
? employee.dependents.filter((d) => {
// 有効期間チェック
if (d.valid_to && new Date(d.valid_to) < new Date())
return false;
// 生年月日から年齢を計算その年の12月31日時点
if (!d.birth_date) return false;
const birthDate = new Date(d.birth_date);
const targetYear = payroll.payroll_year;
const yearEnd = new Date(targetYear, 11, 31); // 12月31日
let age = yearEnd.getFullYear() - birthDate.getFullYear();
const monthDiff = yearEnd.getMonth() - birthDate.getMonth();
if (
monthDiff < 0 ||
(monthDiff === 0 && yearEnd.getDate() < birthDate.getDate())
) {
age--;
}
// 19歳以上のみ源泉税控除対象16-18歳は住民税のみ
return age >= 19;
}).length
: 0;
const html = `
<h2>${payroll.payroll_year}${
payroll.payroll_month
}月 給与明細</h2>
<p>従業員ID: ${payroll.employee_id} | 支給日: ${
<p><strong>従業員:</strong> ${employee.employee_code} - ${
employee.name
} | <strong>支給日:</strong> ${
payroll.payment_date
}</p>
} | <strong>甲欄扶養親族人数(源泉税控除対象):</strong> ${dependentCount}</p>
<p>ステータス: <span class="status-badge status-${
payroll.status
}">${getStatusLabel(payroll.status)}</span></p>
<div class="detail-section">
<h3>勤怠情報</h3>
<h3>勤怠情報 ${
payroll.status === "calculated"
? '<button class="btn btn-primary" onclick="editPayroll(' +
payroll.payroll_id +
')" style="padding: 5px 10px; font-size: 12px; float: right;">編集</button>'
: ""
}</h3>
<div class="detail-row"><span>出勤日数:</span><span>${
payroll.working_days
}日</span></div>
@@ -493,15 +551,27 @@
<div class="detail-row"><span>健康保険:</span><span>¥${Number(
payroll.health_insurance
).toLocaleString()}</span></div>
<div class="detail-row"><span>介護保険:</span><span>¥${Number(
payroll.care_insurance || 0
).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(
<div class="detail-row">
<span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
<span>¥${Number(
payroll.income_tax
).toLocaleString()}</span></div>
).toLocaleString()}</span>
</div>
<div style="padding: 5px 10px; background: #f0f8ff; border-radius: 3px; margin: 5px 0; font-size: 12px; color: #666;">
<small>
※課税対象額から源泉徴収税額表(甲欄)を参照して計算<br>
 扶養親族${dependentCount}19歳以上のみ対象
</small>
</div>
<div class="detail-row"><span>住民税:</span><span>¥${Number(
payroll.resident_tax
).toLocaleString()}</span></div>
@@ -544,6 +614,119 @@
}
}
async function editPayroll(payrollId) {
try {
const response = await fetch(
`${API_BASE}/payroll/calculation/${payrollId}`
);
const payroll = await response.json();
// 編集フォームを表示
const html = `
<div style="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="editPayrollForm" onsubmit="savePayrollEdit(event, ${payrollId})">
<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="${payroll.working_days}" />
</div>
<div class="form-group">
<label>勤務時間</label>
<input type="number" name="working_hours" step="0.5" value="${payroll.working_hours}" />
</div>
<div class="form-group">
<label>残業時間</label>
<input type="number" name="overtime_hours" step="0.5" value="${payroll.overtime_hours}" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>休日出勤時間</label>
<input type="number" name="holiday_hours" step="0.5" value="${payroll.holiday_hours}" />
</div>
<div class="form-group">
<label>遅刻時間</label>
<input type="number" name="late_hours" step="0.5" value="${payroll.late_hours}" />
</div>
<div class="form-group">
<label>欠勤日数</label>
<input type="number" name="absent_days" step="0.5" value="${payroll.absent_days}" />
</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="${payroll.other_allowance}" />
</div>
<div class="form-group">
<label>住民税</label>
<input type="number" name="resident_tax" step="0.01" value="${payroll.resident_tax}" />
</div>
<div class="form-group">
<label>その他控除</label>
<input type="number" name="other_deduction" step="0.01" value="${payroll.other_deduction}" />
</div>
</div>
</div>
<button type="submit" class="btn btn-primary">保存して再計算</button>
<button type="button" class="btn btn-secondary" onclick="this.closest('div').remove()">キャンセル</button>
</form>
</div>
`;
const editDiv = document.createElement("div");
editDiv.innerHTML = html;
document.body.appendChild(editDiv);
} catch (error) {
alert("給与データの取得に失敗しました");
console.error(error);
}
}
async function savePayrollEdit(event, payrollId) {
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/${payrollId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
if (response.ok) {
alert("給与データを更新しました");
form.closest("div").remove();
// 再計算
await recalculatePayroll(payrollId);
} else {
const error = await response.json();
alert("更新に失敗しました: " + error.detail);
}
} catch (error) {
alert("更新に失敗しました");
console.error(error);
}
}
async function approvePayroll(payrollId) {
if (!confirm("この給与を承認しますか?")) return;

File diff suppressed because it is too large Load Diff

View File

@@ -56,25 +56,29 @@
table th {
background: #f0f0f0;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<a href="payroll.html" class="back-link">← 給与管理メニューに戻る</a>
<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="settingsList" style="margin-top: 20px"></div>
<div
id="addForm"
@@ -86,20 +90,39 @@
border-radius: 5px;
"
>
<h3>給与設定の追加</h3>
<h3 id="formTitle">給与設定の追加</h3>
<form onsubmit="saveSalarySetting(event)">
<input type="hidden" name="setting_id" id="setting_id" />
<div class="form-group">
<label>従業員 *</label>
<select name="employee_id" id="employee_id" required>
<option value="">選択してください</option>
</select>
</div>
<div class="form-group">
<label>基本給 (円) *</label>
<input type="number" name="base_salary" step="0.01" required />
<input
type="number"
name="base_salary"
id="base_salary"
step="0.01"
required
/>
</div>
<div class="form-group">
<label>時給 (円)</label>
<input type="number" name="hourly_rate" step="0.01" />
<input
type="number"
name="hourly_rate"
id="hourly_rate"
step="0.01"
/>
<small>時給制の場合に入力してください</small>
</div>
<div class="form-group">
<label>雇用形態 *</label>
<select name="employment_type" required>
<select name="employment_type" id="employment_type" required>
<option value="">選択してください</option>
<option value="正社員">正社員</option>
<option value="契約社員">契約社員</option>
@@ -109,7 +132,7 @@
</div>
<div class="form-group">
<label>支給形態 *</label>
<select name="payment_type" required>
<select name="payment_type" id="payment_type" required>
<option value="">選択してください</option>
<option value="月給">月給</option>
<option value="時給">時給</option>
@@ -121,6 +144,7 @@
<input
type="number"
name="commute_allowance"
id="commute_allowance"
step="0.01"
value="0"
/>
@@ -130,17 +154,31 @@
<input
type="number"
name="other_allowance"
id="other_allowance"
step="0.01"
value="0"
/>
</div>
<div class="form-group">
<label>適用開始日 *</label>
<input type="date" name="valid_from" required />
<input
type="date"
name="valid_from"
id="valid_from"
required
min="1900-01-01"
max="2099-12-31"
/>
</div>
<div class="form-group">
<label>適用終了日</label>
<input type="date" name="valid_to" />
<input
type="date"
name="valid_to"
id="valid_to"
min="1900-01-01"
max="2099-12-31"
/>
<small>通常は空欄のままにしてください</small>
</div>
<button type="submit" class="btn btn-primary">保存</button>
@@ -153,214 +191,239 @@
</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;
const API_BASE = "http://localhost:18080";
let employees = [];
async function loadEmployees() {
try {
const response = await fetch(
`${API_BASE}/payroll/employees/?is_active=true`
);
const employees = await response.json();
employees = await response.json();
const select = document.getElementById("employeeSelect");
const select = document.getElementById("employee_id");
select.innerHTML =
'<option value="">従業員を選択してください</option>' +
'<option value="">選択してください</option>' +
employees
.map(
(emp) =>
`<option value="${emp.employee_id}">${emp.employee_code} - ${emp.name}</option>`
)
.join("");
loadAllSalarySettings();
} catch (error) {
alert("従業員一覧の取得に失敗しました");
console.error(error);
}
}
async function loadSalarySettings() {
const employeeId = document.getElementById("employeeSelect").value;
if (!employeeId) {
document.getElementById("settingsContent").style.display = "none";
return;
async function loadAllSalarySettings() {
try {
// すべての従業員の給与設定を取得
const allSettings = [];
for (const emp of employees) {
try {
const response = await fetch(
`${API_BASE}/payroll/settings/salary/${emp.employee_id}/current`
);
if (response.ok) {
const setting = await response.json();
allSettings.push({
...setting,
employee_code: emp.employee_code,
employee_name: emp.name,
employee_id: emp.employee_id,
});
}
} catch (error) {
console.error(
`従業員 ${emp.employee_id} の給与設定取得失敗`,
error
);
}
}
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);
displayAllSettings(allSettings);
} 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();
function displayAllSettings(settings) {
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()
<table>
<thead>
<tr>
<th>従業員コード</th>
<th>氏名</th>
<th>基本給</th>
<th>時給</th>
<th>雇用形態</th>
<th>支給形態</th>
<th>通勤手当</th>
<th>その他手当</th>
<th>適用期間</th>
<th>操作</th>
</tr>
</thead>
<tbody>
${
settings.length > 0
? settings
.map(
(s) => `
<tr>
<td>${s.employee_code}</td>
<td>${s.employee_name}</td>
<td>¥${Number(s.base_salary).toLocaleString()}</td>
<td>${
s.hourly_rate
? "¥" + Number(s.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>
}</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>
<td>${s.valid_from} ${s.valid_to || "現在"}</td>
<td>
<button class="btn btn-primary" onclick="editSetting(${
s.setting_id
})" style="padding: 5px 10px; font-size: 12px;">編集</button>
</td>
</tr>
`
)
.join("")
: '<tr><td colspan="10" style="text-align:center;">給与設定が登録されていません</td></tr>'
}
</tbody>
</table>
`;
document.getElementById("currentSettingContent").innerHTML = html;
} else {
document.getElementById("currentSettingContent").innerHTML =
"<p>給与設定が登録されていません</p>";
document.getElementById("settingsList").innerHTML = html;
}
async function editSetting(settingId) {
try {
// 設定IDから詳細を取得する必要がありますが、現在のAPIでは難しいので
// 全従業員の設定を取得して該当するものを探す
for (const emp of employees) {
const response = await fetch(
`${API_BASE}/payroll/settings/salary/${emp.employee_id}`
);
const settings = await response.json();
const setting = settings.find((s) => s.setting_id === settingId);
if (setting) {
// フォームに値を設定
document.getElementById("formTitle").textContent =
"給与設定の編集";
document.getElementById("setting_id").value = setting.setting_id;
document.getElementById("employee_id").value = emp.employee_id;
document.getElementById("employee_id").disabled = true; // 従業員は変更不可
document.getElementById("base_salary").value =
setting.base_salary;
document.getElementById("hourly_rate").value =
setting.hourly_rate || "";
document.getElementById("employment_type").value =
setting.employment_type;
document.getElementById("payment_type").value =
setting.payment_type;
document.getElementById("commute_allowance").value =
setting.commute_allowance;
document.getElementById("other_allowance").value =
setting.other_allowance;
document.getElementById("valid_from").value = setting.valid_from;
document.getElementById("valid_to").value =
setting.valid_to || "";
document.getElementById("addForm").style.display = "block";
return;
}
}
} catch (error) {
document.getElementById("currentSettingContent").innerHTML =
"<p>給与設定の取得に失敗しました</p>";
alert("給与設定の取得に失敗しました");
console.error(error);
}
}
function showAddForm() {
document.getElementById("formTitle").textContent = "給与設定の追加";
document.getElementById("addForm").style.display = "block";
document.querySelector("#addForm form").reset();
document.getElementById("setting_id").value = "";
document.getElementById("employee_id").disabled = false;
// デフォルト値設定
document.querySelector('[name="valid_from"]').value = new Date()
document.getElementById("valid_from").value = new Date()
.toISOString()
.split("T")[0];
document.getElementById("commute_allowance").value = "0";
document.getElementById("other_allowance").value = "0";
}
function hideAddForm() {
document.getElementById("addForm").style.display = "none";
document.querySelector("#addForm form").reset();
document.getElementById("employee_id").disabled = false;
}
async function saveSalarySetting(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
const settingId = document.getElementById("setting_id").value;
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;
const data = {
employee_id: parseInt(formData.get("employee_id")),
base_salary: parseFloat(formData.get("base_salary")) || 0,
employment_type: formData.get("employment_type"),
payment_type: formData.get("payment_type"),
commute_allowance: parseFloat(formData.get("commute_allowance")) || 0,
other_allowance: parseFloat(formData.get("other_allowance")) || 0,
valid_from: formData.get("valid_from"),
};
if (data.hourly_rate) {
data.hourly_rate = parseFloat(data.hourly_rate);
} else {
delete data.hourly_rate;
if (formData.get("hourly_rate")) {
data.hourly_rate = parseFloat(formData.get("hourly_rate"));
}
if (!data.valid_to) {
delete data.valid_to;
if (formData.get("valid_to")) {
data.valid_to = formData.get("valid_to");
}
try {
const response = await fetch(`${API_BASE}/payroll/settings/salary`, {
let response;
if (settingId) {
// 更新
response = await fetch(
`${API_BASE}/payroll/settings/salary/${settingId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
}
);
} else {
// 新規追加
response = await fetch(`${API_BASE}/payroll/settings/salary`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
}
if (response.ok) {
alert("給与設定を保存しました");
alert(
settingId ? "給与設定を更新しました" : "給与設定を保存しました"
);
hideAddForm();
loadSalarySettings();
loadAllSalarySettings();
} else {
const error = await response.json();
alert("保存に失敗しました: " + error.detail);

View File

@@ -114,10 +114,57 @@
border-left: 4px solid #007bff;
margin-bottom: 20px;
}
.year-item {
padding: 15px;
margin: 10px 0;
background: #f9f9f9;
border: 1px solid #ddd;
border-radius: 5px;
cursor: pointer;
transition: background 0.2s;
}
.year-item:hover {
background: #e8f4ff;
}
.year-item.expanded {
background: #e8f4ff;
border-color: #007bff;
}
.year-header {
font-size: 18px;
font-weight: bold;
color: #333;
}
.year-data {
display: none;
margin-top: 15px;
}
.year-item.expanded .year-data {
display: block;
}
.filter-controls {
margin-bottom: 15px;
padding: 10px;
background: #fff;
border: 1px solid #ddd;
border-radius: 4px;
}
.back-link {
display: inline-block;
margin-bottom: 20px;
color: #007bff;
text-decoration: none;
font-size: 14px;
}
.back-link:hover {
text-decoration: underline;
}
</style>
</head>
<body>
<div class="container">
<a href="payroll.html" class="back-link">← 給与管理メニューに戻る</a>
<h1>給与管理 - 設定</h1>
<div class="nav-tabs">
@@ -129,6 +176,9 @@
</button>
<button class="nav-tab" onclick="showTab('limits')">上限設定</button>
<button class="nav-tab" onclick="showTab('tax')">所得税率表</button>
<button class="nav-tab" onclick="showTab('standard-remuneration')">
標準報酬月額表
</button>
</div>
<!-- 保険料率タブ -->
@@ -459,11 +509,22 @@
<div class="form-row">
<div class="form-group">
<label>適用開始日 *</label>
<input type="date" name="valid_from" required />
<input
type="date"
name="valid_from"
required
min="1900-01-01"
max="2099-12-31"
/>
</div>
<div class="form-group">
<label>適用終了日</label>
<input type="date" name="valid_to" />
<input
type="date"
name="valid_to"
min="1900-01-01"
max="2099-12-31"
/>
</div>
</div>
@@ -487,18 +548,6 @@
<!-- 所得税率表タブ -->
<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()"
@@ -512,7 +561,40 @@
style="display: none"
onchange="importTaxTable(event)"
/>
<div id="taxList"></div>
<div id="taxYearsList" style="margin-top: 20px"></div>
<div id="taxDataContainer" style="margin-top: 20px"></div>
</div>
<!-- 標準報酬月額表タブ -->
<div id="tab-standard-remuneration" class="tab-content">
<h2>標準報酬月額表</h2>
<div class="info-box">
<strong>標準報酬月額表について:</strong>
<ul style="margin: 10px 0 0 20px">
<li>健康保険・厚生年金保険の保険料額表をインポートします</li>
<li>都道府県ごとの料率表に対応しています</li>
<li>年度と都道府県を指定してください</li>
</ul>
</div>
<div style="margin: 20px 0">
<button
class="btn btn-primary"
onclick="document.getElementById('stdRemunerationFile').click()"
>
Excel/CSVインポート
</button>
<input
type="file"
id="stdRemunerationFile"
accept=".csv,.xlsx,.xls"
style="display: none"
onchange="importStdRemunerationTable(event)"
/>
</div>
<div id="stdRemunerationYearsList" style="margin-top: 20px"></div>
<div id="stdRemunerationDataContainer" style="margin-top: 20px"></div>
</div>
</div>
@@ -535,6 +617,7 @@
"child-support": 1,
limits: 2,
tax: 3,
"standard-remuneration": 4,
};
document
@@ -551,6 +634,8 @@
loadLimitSettings();
} else if (tabName === "tax") {
loadIncomeTax();
} else if (tabName === "standard-remuneration") {
loadStdRemunerationData();
}
}
@@ -1189,21 +1274,95 @@
// =====================================================
// 所得税率表関連
// =====================================================
let currentExpandedYear = null;
async function loadIncomeTax() {
const dependentsFilter =
document.getElementById("filterDependents").value;
const params = dependentsFilter
? `?dependents_count=${dependentsFilter}`
: "";
await loadIncomeTaxYears();
}
async function loadIncomeTaxYears() {
try {
const response = await fetch(
`${API_BASE}/payroll/settings/income-tax/years`
);
const data = await response.json();
if (!data.years || data.years.length === 0) {
document.getElementById("taxYearsList").innerHTML =
"<p>データがありません。</p>";
return;
}
const html = data.years
.map(
(year) => `
<div class="year-item" id="year-${year}" onclick="toggleYear(${year})">
<div class="year-header">
📅 ${year}年度 <span style="font-size: 14px; color: #666;">(クリックして展開)</span>
</div>
<div class="year-data" id="data-${year}">
<div class="filter-controls">
<label>扶養人数でフィルター: </label>
<select id="filter-${year}" onchange="loadYearData(${year})">
<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>
<option value="6">6人</option>
<option value="7">7人</option>
</select>
</div>
<div id="table-${year}">読み込み中...</div>
</div>
</div>
`
)
.join("");
document.getElementById("taxYearsList").innerHTML = html;
} catch (error) {
alert("年度リストの取得に失敗しました");
console.error(error);
}
}
async function toggleYear(year) {
const yearItem = document.getElementById(`year-${year}`);
const wasExpanded = yearItem.classList.contains("expanded");
// 他の年度を閉じる
document.querySelectorAll(".year-item").forEach((item) => {
item.classList.remove("expanded");
});
if (!wasExpanded) {
yearItem.classList.add("expanded");
currentExpandedYear = year;
await loadYearData(year);
} else {
currentExpandedYear = null;
}
}
async function loadYearData(year) {
const filterSelect = document.getElementById(`filter-${year}`);
const dependentsFilter = filterSelect ? filterSelect.value : "";
const params = new URLSearchParams({ tax_year: year });
if (dependentsFilter) {
params.append("dependents_count", dependentsFilter);
}
try {
const response = await fetch(
`${API_BASE}/payroll/settings/income-tax${params}`
`${API_BASE}/payroll/settings/income-tax?${params}`
);
const taxes = await response.json();
if (taxes.length === 0) {
document.getElementById("taxList").innerHTML =
document.getElementById(`table-${year}`).innerHTML =
"<p>データがありません。</p>";
return;
}
@@ -1228,9 +1387,7 @@
<td>¥${Number(
tax.monthly_income_from
).toLocaleString()}</td>
<td>¥${Number(
tax.monthly_income_to
).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>
@@ -1241,9 +1398,9 @@
</table>
`;
document.getElementById("taxList").innerHTML = html;
document.getElementById(`table-${year}`).innerHTML = html;
} catch (error) {
alert("所得税率表の取得に失敗しました");
alert("データの取得に失敗しました");
console.error(error);
}
}
@@ -1274,7 +1431,7 @@
if (response.ok) {
const result = await response.json();
alert(result.message + `\n年度: ${result.year}`);
loadIncomeTax();
loadIncomeTaxYears();
} else {
const error = await response.json();
alert("インポートに失敗しました: " + JSON.stringify(error.detail));
@@ -1287,6 +1444,224 @@
event.target.value = "";
}
// =====================================================
// 標準報酬月額表関連の関数
// =====================================================
async function loadStdRemunerationData() {
try {
const response = await fetch(
`${API_BASE}/payroll/settings/standard-remuneration/years`
);
if (!response.ok) {
throw new Error("Failed to load years");
}
const years = await response.json();
if (!years || years.length === 0) {
document.getElementById("stdRemunerationYearsList").innerHTML =
"<p>データがありません。Excelファイルをインポートしてください。</p>";
return;
}
const html = years
.map(
(year) => `
<div class="year-item" id="std-year-${year}">
<div class="year-header" onclick="toggleStdRemunerationYear(${year})">
<span class="year-title">${year}年度</span>
<span class="expand-icon">▼</span>
</div>
<div class="year-content" id="std-content-${year}"></div>
</div>
`
)
.join("");
document.getElementById("stdRemunerationYearsList").innerHTML = html;
} catch (error) {
alert("年度リストの取得に失敗しました");
console.error(error);
}
}
async function toggleStdRemunerationYear(year) {
const yearItem = document.getElementById(`std-year-${year}`);
const wasExpanded = yearItem.classList.contains("expanded");
// 他の年度を閉じる
document.querySelectorAll(".year-item").forEach((item) => {
item.classList.remove("expanded");
});
if (!wasExpanded) {
yearItem.classList.add("expanded");
await loadStdRemunerationYearData(year);
} else {
document.getElementById(`std-content-${year}`).innerHTML = "";
}
}
async function loadStdRemunerationYearData(year) {
const filterSelect = document.getElementById(`std-filter-${year}`);
const prefectureFilter = filterSelect ? filterSelect.value : "";
const params = new URLSearchParams({ year: year });
if (prefectureFilter) {
params.append("prefecture", prefectureFilter);
}
try {
const response = await fetch(
`${API_BASE}/payroll/settings/standard-remuneration?${params}`
);
const data = await response.json();
if (data.length === 0) {
document.getElementById(`std-content-${year}`).innerHTML =
'<p class="no-data">データがありません</p>';
return;
}
// 都道府県でグループ化
const groupedByPrefecture = {};
data.forEach((row) => {
if (!groupedByPrefecture[row.prefecture]) {
groupedByPrefecture[row.prefecture] = [];
}
groupedByPrefecture[row.prefecture].push(row);
});
// 都道府県一覧を取得
const prefectures = Object.keys(groupedByPrefecture).sort();
const html = `
<div class="filter-section">
<label>都道府県で絞り込み:</label>
<select id="std-filter-${year}" onchange="loadStdRemunerationYearData(${year})" style="margin-left: 10px">
<option value="">すべて</option>
${prefectures
.map(
(pref) =>
`<option value="${pref}" ${
prefectureFilter === pref ? "selected" : ""
}>${pref}</option>`
)
.join("")}
</select>
</div>
${Object.entries(groupedByPrefecture)
.map(
([prefecture, rows]) => `
<div style="margin-top: 20px">
<h4>${prefecture}</h4>
<table>
<thead>
<tr>
<th rowspan="2">等級</th>
<th rowspan="2">標準報酬月額</th>
<th colspan="2">報酬月額</th>
<th colspan="2">健康保険料</th>
<th rowspan="2">厚生年金保険料</th>
</tr>
<tr>
<th>以上</th>
<th>未満</th>
<th>介護保険なし</th>
<th>介護保険あり</th>
</tr>
</thead>
<tbody>
${rows
.map(
(row) => `
<tr>
<td>${row.grade}</td>
<td class="number">${formatNumber(
row.monthly_amount
)}</td>
<td class="number">${formatNumber(row.salary_from)}</td>
<td class="number">${
row.salary_to ? formatNumber(row.salary_to) : "〜"
}</td>
<td class="number">${formatNumber(
row.health_insurance_no_care
)}</td>
<td class="number">${formatNumber(
row.health_insurance_with_care
)}</td>
<td class="number">${formatNumber(
row.pension_insurance
)}</td>
</tr>
`
)
.join("")}
</tbody>
</table>
</div>
`
)
.join("")}
`;
document.getElementById(`std-content-${year}`).innerHTML = html;
} catch (error) {
alert("データの取得に失敗しました");
console.error(error);
}
}
async function importStdRemunerationTable(event) {
const file = event.target.files[0];
if (!file) return;
const prefecture = prompt(
"都道府県名を入力してください(例: 東京):",
""
);
if (!prefecture || !prefecture.trim()) {
alert("都道府県名を入力してください");
event.target.value = "";
return;
}
const formData = new FormData();
formData.append("file", file);
formData.append("prefecture", prefecture.trim());
try {
const response = await fetch(
`${API_BASE}/payroll/settings/standard-remuneration/import`,
{
method: "POST",
body: formData,
}
);
if (response.ok) {
const result = await response.json();
alert(
`インポートしました: ${result.imported_count}\n年度: ${
result.year
}\n都道府県: ${prefecture}\n\n取得元: ${result.source || ""}`
);
loadStdRemunerationData();
} else {
const error = await response.json();
alert("インポートに失敗しました: " + JSON.stringify(error.detail));
}
} catch (error) {
alert("インポートに失敗しました");
console.error(error);
}
event.target.value = "";
}
function formatNumber(num) {
if (num === null || num === undefined) return "-";
return Number(num).toLocaleString("ja-JP");
}
// =====================================================
// 初期化
// =====================================================

View File

@@ -99,10 +99,10 @@
<div class="search-form">
<label for="dateFrom">開始年月日:</label>
<input type="date" id="dateFrom" min="1900-01-01" max="9999-12-31" />
<input type="date" id="dateFrom" min="1900-01-01" max="2099-12-31" />
<label for="dateTo">終了年月日:</label>
<input type="date" id="dateTo" min="1900-01-01" max="9999-12-31" />
<input type="date" id="dateTo" min="1900-01-01" max="2099-12-31" />
<button id="btnSearch">検索</button>
<button id="btnFullYear" class="secondary">全年度表示</button>

BIN
reports/tmp/r7ippan3.xlsx Normal file

Binary file not shown.