diff --git a/backend/add_my_number.py b/backend/add_my_number.py new file mode 100644 index 0000000..5ade2f2 --- /dev/null +++ b/backend/add_my_number.py @@ -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() diff --git a/backend/app/main.py b/backend/app/main.py index 1616c85..d34291b 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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( diff --git a/backend/app/payroll/bonus/service.py b/backend/app/payroll/bonus/service.py index 198c59d..3ecc305 100644 --- a/backend/app/payroll/bonus/service.py +++ b/backend/app/payroll/bonus/service.py @@ -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) diff --git a/backend/app/payroll/calculation/schemas.py b/backend/app/payroll/calculation/schemas.py index 00e4ec9..163c29f 100644 --- a/backend/app/payroll/calculation/schemas.py +++ b/backend/app/payroll/calculation/schemas.py @@ -81,6 +81,7 @@ class MonthlyPayroll(MonthlyPayrollBase): # 控除項目 health_insurance: Decimal + care_insurance: Decimal pension_insurance: Decimal employment_insurance: Decimal income_tax: Decimal diff --git a/backend/app/payroll/calculation/service.py b/backend/app/payroll/calculation/service.py index d9128b0..3dc6e2f 100644 --- a/backend/app/payroll/calculation/service.py +++ b/backend/app/payroll/calculation/service.py @@ -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) diff --git a/backend/app/payroll/employees/router.py b/backend/app/payroll/employees/router.py index f2a3fe1..0a51cd1 100644 --- a/backend/app/payroll/employees/router.py +++ b/backend/app/payroll/employees/router.py @@ -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() diff --git a/backend/app/payroll/employees/schemas.py b/backend/app/payroll/employees/schemas.py index 5edda0e..a6f64a6 100644 --- a/backend/app/payroll/employees/schemas.py +++ b/backend/app/payroll/employees/schemas.py @@ -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): diff --git a/backend/app/payroll/settings/router.py b/backend/app/payroll/settings/router.py index bbc8b82..ba417b7 100644 --- a/backend/app/payroll/settings/router.py +++ b/backend/app/payroll/settings/router.py @@ -427,7 +427,7 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None): # .xlsファイルの処理完了 conn.commit() print(f"インポート完了: {imported_count}件のデータをインポートしました") - return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました"} + return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました", "year": extracted_year} else: # .xlsx形式 - openpyxlを使用 @@ -568,7 +568,7 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None): # .xlsxファイルの処理完了 conn.commit() print(f"インポート完了: {imported_count}件のデータをインポートしました") - return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました"} + return {"message": f"{extracted_year}年度の所得税率表を{imported_count}件インポートしました", "year": extracted_year} # CSV file処理 else: @@ -611,35 +611,75 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None): return {"message": f"{extracted_year}年度のデータを{imported_count}件インポートしました(既存データを置き換え)", "year": extracted_year} -@router.get("/income-tax", response_model=List[schemas.IncomeTaxTable]) -def get_income_tax_table(target_date: date = None, dependents_count: int = None): - """所得税率表を取得""" - if target_date is None: - target_date = date.today() - +@router.get("/income-tax/years") +def get_income_tax_years(): + """所得税率表の年度一覧を取得""" with get_connection() as conn: with conn.cursor() as cur: - if dependents_count is not None: - cur.execute( - """ - SELECT * FROM income_tax_table - WHERE valid_from <= %s - AND (valid_to IS NULL OR valid_to >= %s) - AND dependents_count = %s - ORDER BY monthly_income_from - """, - (target_date, target_date, dependents_count) - ) + cur.execute( + """ + SELECT DISTINCT tax_year + FROM income_tax_table + WHERE tax_year IS NOT NULL + ORDER BY tax_year DESC + """ + ) + years = [row['tax_year'] for row in cur.fetchall()] + return {"years": years} + + +@router.get("/income-tax", response_model=List[schemas.IncomeTaxTable]) +def get_income_tax_table(target_date: date = None, dependents_count: int = None, tax_year: int = None): + """所得税率表を取得""" + with get_connection() as conn: + with conn.cursor() as cur: + # tax_yearが指定されている場合はそれを優先 + if tax_year is not None: + if dependents_count is not None: + cur.execute( + """ + SELECT * FROM income_tax_table + WHERE tax_year = %s + AND dependents_count = %s + ORDER BY monthly_income_from + """, + (tax_year, dependents_count) + ) + else: + cur.execute( + """ + SELECT * FROM income_tax_table + WHERE tax_year = %s + ORDER BY dependents_count, monthly_income_from + """, + (tax_year,) + ) else: - cur.execute( - """ - SELECT * FROM income_tax_table - WHERE valid_from <= %s - AND (valid_to IS NULL OR valid_to >= %s) - ORDER BY dependents_count, monthly_income_from - """, - (target_date, target_date) - ) + # target_dateで検索 + if target_date is None: + target_date = date.today() + + if dependents_count is not None: + cur.execute( + """ + SELECT * FROM income_tax_table + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + AND dependents_count = %s + ORDER BY monthly_income_from + """, + (target_date, target_date, dependents_count) + ) + else: + cur.execute( + """ + SELECT * FROM income_tax_table + WHERE valid_from <= %s + AND (valid_to IS NULL OR valid_to >= %s) + ORDER BY dependents_count, monthly_income_from + """, + (target_date, target_date) + ) return cur.fetchall() diff --git a/backend/app/payroll/settings/standard_remuneration_router.py b/backend/app/payroll/settings/standard_remuneration_router.py new file mode 100644 index 0000000..289bb98 --- /dev/null +++ b/backend/app/payroll/settings/standard_remuneration_router.py @@ -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', '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)) diff --git a/backend/app/routers/file_upload.py b/backend/app/routers/file_upload.py new file mode 100644 index 0000000..5dfa804 --- /dev/null +++ b/backend/app/routers/file_upload.py @@ -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)}") diff --git a/backend/check_insurance_rates.py b/backend/check_insurance_rates.py new file mode 100644 index 0000000..c806ea5 --- /dev/null +++ b/backend/check_insurance_rates.py @@ -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() diff --git a/backend/check_photo_data.py b/backend/check_photo_data.py new file mode 100644 index 0000000..499eadc --- /dev/null +++ b/backend/check_photo_data.py @@ -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() diff --git a/backend/check_std_table.py b/backend/check_std_table.py new file mode 100644 index 0000000..76f94d5 --- /dev/null +++ b/backend/check_std_table.py @@ -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() diff --git a/backend/create_std_remuneration_table.py b/backend/create_std_remuneration_table.py new file mode 100644 index 0000000..79e55d6 --- /dev/null +++ b/backend/create_std_remuneration_table.py @@ -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() diff --git a/backend/create_std_table.py b/backend/create_std_table.py new file mode 100644 index 0000000..c390ba2 --- /dev/null +++ b/backend/create_std_table.py @@ -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() diff --git a/backend/debug_care_insurance.py b/backend/debug_care_insurance.py new file mode 100644 index 0000000..7966b8f --- /dev/null +++ b/backend/debug_care_insurance.py @@ -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) diff --git a/backend/fix_photo_columns.py b/backend/fix_photo_columns.py new file mode 100644 index 0000000..8a134de --- /dev/null +++ b/backend/fix_photo_columns.py @@ -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() diff --git a/backend/sql/add_employee_photos.sql b/backend/sql/add_employee_photos.sql new file mode 100644 index 0000000..7b385e3 --- /dev/null +++ b/backend/sql/add_employee_photos.sql @@ -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'; diff --git a/backend/sql/add_my_number_field.sql b/backend/sql/add_my_number_field.sql new file mode 100644 index 0000000..13b4229 --- /dev/null +++ b/backend/sql/add_my_number_field.sql @@ -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; diff --git a/backend/sql/create_standard_remuneration.sql b/backend/sql/create_standard_remuneration.sql new file mode 100644 index 0000000..e3214bc --- /dev/null +++ b/backend/sql/create_standard_remuneration.sql @@ -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 '厚生年金保険料'; diff --git a/backend/sql/fix_photo_column_size.sql b/backend/sql/fix_photo_column_size.sql new file mode 100644 index 0000000..ef1e135 --- /dev/null +++ b/backend/sql/fix_photo_column_size.sql @@ -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'; diff --git a/backend/sql/insert_2025_insurance_rates.sql b/backend/sql/insert_2025_insurance_rates.sql new file mode 100644 index 0000000..772f9fe --- /dev/null +++ b/backend/sql/insert_2025_insurance_rates.sql @@ -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; diff --git a/backend/test_care_insurance.py b/backend/test_care_insurance.py new file mode 100644 index 0000000..c7c8172 --- /dev/null +++ b/backend/test_care_insurance.py @@ -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() diff --git a/backend/uploads/employees/20190002/20190002_20260120_165205_10b99e35.png b/backend/uploads/employees/20190002/20190002_20260120_165205_10b99e35.png new file mode 100644 index 0000000..c06b901 Binary files /dev/null and b/backend/uploads/employees/20190002/20190002_20260120_165205_10b99e35.png differ diff --git a/frontend/index.html b/frontend/index.html index 5b78c0b..2d1c0c3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -10,9 +10,9 @@ diff --git a/frontend/journal-edit.html b/frontend/journal-edit.html index 721fdd0..ff240d5 100644 --- a/frontend/journal-edit.html +++ b/frontend/journal-edit.html @@ -44,7 +44,7 @@