From 3d91356877a6e8a27d2c97df478a3c26801d357f Mon Sep 17 00:00:00 2001 From: admin Date: Thu, 22 Jan 2026 01:16:54 +0900 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 18 +- backend/app/payroll/bonus/router.py | 63 + backend/app/payroll/bonus/service.py | 15 +- backend/app/payroll/calculation/router.py | 4 +- backend/app/payroll/calculation/service.py | 72 +- backend/app/payroll/employees/schemas.py | 2 + backend/app/payroll/settings/router.py | 107 +- backend/app/payroll/settings/schemas.py | 2 + .../settings/standard_remuneration_router.py | 199 +++- .../sql/add_employment_insurance_eligible.sql | 5 + backend/sql/add_mynumber_to_dependents.sql | 4 + backend/sql/payroll_schema.sql | 2 + frontend/payroll-calculation.html | 1015 ++++++++++++++--- frontend/payroll-employees.html | 51 +- frontend/payroll-salary-settings.html | 26 +- frontend/payroll-settings.html | 771 +++++++++---- 16 files changed, 1862 insertions(+), 494 deletions(-) create mode 100644 backend/sql/add_employment_insurance_eligible.sql create mode 100644 backend/sql/add_mynumber_to_dependents.sql diff --git a/backend/app/main.py b/backend/app/main.py index d34291b..853f273 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -123,14 +123,16 @@ app.mount( # name="static", # ) -# フロントエンドファイル配信(コメントアウト - file://プロトコルで直接アクセスするため不要) -# frontend_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend") -# if os.path.exists(frontend_path): -# app.mount( -# "/", -# StaticFiles(directory=frontend_path, html=True), -# name="frontend", -# ) +# フロントエンドファイル配信 +frontend_path = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(__file__))), "frontend") +if os.path.exists(frontend_path): + app.mount( + "/frontend", + StaticFiles(directory=frontend_path, html=True), + name="frontend", + ) +else: + print(f"Frontend path not found: {frontend_path}") diff --git a/backend/app/payroll/bonus/router.py b/backend/app/payroll/bonus/router.py index 4f1446d..01611e1 100644 --- a/backend/app/payroll/bonus/router.py +++ b/backend/app/payroll/bonus/router.py @@ -128,6 +128,69 @@ def approve_bonus(bonus_id: int, approved_by: str): return result +@router.post("/{bonus_id}/recalculate", response_model=schemas.BonusPayment) +def recalculate_bonus(bonus_id: int): + """賞与を再計算""" + with get_connection() as conn: + with conn.cursor() as cur: + # 既存データを取得 + cur.execute("SELECT * FROM bonus_payments WHERE bonus_id = %s", (bonus_id,)) + existing = cur.fetchone() + + if not existing: + raise HTTPException(status_code=404, detail="賞与データが見つかりません") + + if existing["status"] == "approved": + raise HTTPException(status_code=400, detail="承認済みの賞与は再計算できません") + + # 再計算 + try: + calc_result = BonusCalculationService.calculate_bonus( + employee_id=existing["employee_id"], + bonus_year=existing["bonus_year"], + bonus_month=existing["bonus_month"], + bonus_type=existing["bonus_type"], + payment_date=existing["payment_date"], + base_bonus=existing["base_bonus"], + performance_bonus=existing["performance_bonus"], + other_bonus=existing["other_bonus"], + other_deduction=existing["other_deduction"], + calculated_by="recalculated" + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + # 更新 + cur.execute( + """ + UPDATE bonus_payments SET + base_bonus = %s, performance_bonus = %s, other_bonus = %s, + total_bonus = %s, + health_insurance = %s, care_insurance = %s, pension_insurance = %s, + employment_insurance = %s, income_tax = %s, other_deduction = %s, + total_deduction = %s, net_bonus = %s, + calculated_at = %s, calculated_by = %s + WHERE bonus_id = %s + RETURNING * + """, + ( + calc_result["base_bonus"], calc_result["performance_bonus"], + calc_result["other_bonus"], calc_result["total_bonus"], + calc_result["health_insurance"], calc_result["care_insurance"], + calc_result["pension_insurance"], calc_result["employment_insurance"], + calc_result["income_tax"], calc_result["other_deduction"], + calc_result["total_deduction"], calc_result["net_bonus"], + datetime.now(), "recalculated", + bonus_id + ) + ) + result = cur.fetchone() + if not result: + raise HTTPException(status_code=404, detail="賞与を更新できません") + conn.commit() + return result + + @router.delete("/{bonus_id}", status_code=status.HTTP_204_NO_CONTENT) def delete_bonus(bonus_id: int): """賞与を削除""" diff --git a/backend/app/payroll/bonus/service.py b/backend/app/payroll/bonus/service.py index 3ecc305..0e64e2b 100644 --- a/backend/app/payroll/bonus/service.py +++ b/backend/app/payroll/bonus/service.py @@ -58,12 +58,15 @@ class BonusCalculationService: cur.execute( """ SELECT AVG(total_payment - commute_allowance) as avg_salary - FROM monthly_payroll - WHERE employee_id = %s - AND payment_date < %s - AND status IN ('approved', 'paid') - ORDER BY payment_date DESC - LIMIT 3 + FROM ( + SELECT total_payment, commute_allowance + FROM monthly_payroll + WHERE employee_id = %s + AND payment_date < %s + AND status IN ('approved', 'paid') + ORDER BY payment_date DESC + LIMIT 3 + ) as recent_payroll """, (employee_id, payment_date) ) diff --git a/backend/app/payroll/calculation/router.py b/backend/app/payroll/calculation/router.py index fedc8e8..17acbdd 100644 --- a/backend/app/payroll/calculation/router.py +++ b/backend/app/payroll/calculation/router.py @@ -262,7 +262,7 @@ def approve_payroll(payroll_id: int, request: schemas.PayrollApprovalRequest): return result -@router.delete("/{payroll_id}", status_code=status.HTTP_204_NO_CONTENT) +@router.delete("/{payroll_id}") def delete_payroll(payroll_id: int): """給与データを削除""" with get_connection() as conn: @@ -282,3 +282,5 @@ def delete_payroll(payroll_id: int): cur.execute("DELETE FROM monthly_payroll WHERE payroll_id = %s", (payroll_id,)) conn.commit() + + return {"message": "給与データを削除しました", "payroll_id": payroll_id} diff --git a/backend/app/payroll/calculation/service.py b/backend/app/payroll/calculation/service.py index 3dc6e2f..acfc790 100644 --- a/backend/app/payroll/calculation/service.py +++ b/backend/app/payroll/calculation/service.py @@ -132,22 +132,25 @@ class PayrollCalculationService: ) -> Dict[str, Any]: """給与を計算""" + # バリデーションエラーを集約 + errors = [] + # 給与設定を取得 salary_setting = PayrollCalculationService.get_salary_setting(employee_id, payment_date) if not salary_setting: - raise ValueError("給与設定が見つかりません") + errors.append("給与設定が見つかりません") # 基本給の計算 - base_salary = Decimal(str(salary_setting["base_salary"])) + base_salary = Decimal(str(salary_setting["base_salary"])) if salary_setting else Decimal("0") # 時給の場合は勤務時間から計算 - if salary_setting["payment_type"] == "時給" and salary_setting["hourly_rate"]: + if salary_setting and salary_setting["payment_type"] == "時給" and salary_setting["hourly_rate"]: hourly_rate = Decimal(str(salary_setting["hourly_rate"])) base_salary = hourly_rate * working_hours # 欠勤控除(日給制の場合) absence_deduction = Decimal("0") - if absent_days > 0 and salary_setting["payment_type"] == "月給": + if absent_days > 0 and salary_setting and salary_setting["payment_type"] == "月給": # 月給を営業日数で割って欠勤日数を掛ける(簡易計算) daily_rate = base_salary / Decimal("22") # 月平均営業日数を22日と仮定 absence_deduction = daily_rate * absent_days @@ -156,11 +159,11 @@ class PayrollCalculationService: # 残業手当の計算(時給 × 1.25) overtime_pay = Decimal("0") if overtime_hours > 0: - if salary_setting["hourly_rate"]: + if salary_setting and salary_setting["hourly_rate"]: hourly_rate = Decimal(str(salary_setting["hourly_rate"])) else: # 月給を時給に換算(月平均労働時間を160時間と仮定) - hourly_rate = Decimal(str(salary_setting["base_salary"])) / Decimal("160") + hourly_rate = (Decimal(str(salary_setting["base_salary"])) if salary_setting else Decimal("0")) / Decimal("160") overtime_pay = hourly_rate * overtime_hours * Decimal("1.25") # 休日手当の計算(時給 × 1.35) @@ -181,8 +184,29 @@ class PayrollCalculationService: commute_allowance + other_allowance ) - # 社会保険料の計算(標準報酬月額を総支給額と仮定) + # 標準報酬月額表から標準報酬月額を検索 standard_monthly_salary = total_payment + with get_connection() as conn: + with conn.cursor() as cur: + # 給与年月の標準報酬月額表から、総支給額が属する等級を検索 + cur.execute( + """ + SELECT monthly_amount + FROM standard_remuneration + WHERE rate_year = %s + AND salary_from <= %s + AND (salary_to IS NULL OR salary_to > %s) + ORDER BY rate_year DESC, salary_from DESC + LIMIT 1 + """, + (payment_date.year, total_payment, total_payment) + ) + result = cur.fetchone() + if result: + standard_monthly_salary = Decimal(str(result["monthly_amount"])) + print(f"[DEBUG] 標準報酬月額表検索: 総支給額={total_payment} → 標準報酬月額={standard_monthly_salary}") + else: + print(f"[DEBUG] 標準報酬月額表検索: 該当等級なし(総支給額={total_payment})") # 社会保険料上限を取得 health_insurance_limit = PayrollCalculationService.get_insurance_limit( @@ -218,22 +242,27 @@ class PayrollCalculationService: if (payment_date.month, payment_date.day) < (birth_date.month, birth_date.day): age -= 1 - # 健康保険(上限適用後の標準報酬月額で計算) + # 健康保険(標準報酬月額表から検索した月額で計算) health_insurance_rate = PayrollCalculationService.get_insurance_rate("健康保険", payment_date) health_insurance = Decimal("0") if health_insurance_rate: health_insurance = ( health_standard_salary * Decimal(str(health_insurance_rate["employee_rate"])) - ).quantize(Decimal("1")) + ).quantize(Decimal("0.01")) + else: + errors.append(f"健康保険料率が見つかりません(年度: {payment_date.year})") - # 介護保険(40歳以上65歳未満が対象、健康保険と同じ標準報酬月額) + # 介護保険(40歳以上65歳未満が対象、標準報酬月額表から検索した月額で計算) care_insurance = Decimal("0") if 40 <= age < 65: care_insurance_rate = PayrollCalculationService.get_insurance_rate("介護保険", payment_date) if care_insurance_rate: care_insurance = ( health_standard_salary * Decimal(str(care_insurance_rate["employee_rate"])) - ).quantize(Decimal("1")) + ).quantize(Decimal("0.01")) + print(f"[DEBUG] 介護保険計算: {health_standard_salary} × {care_insurance_rate['employee_rate']}% = {care_insurance}") + else: + errors.append(f"介護保険料率が見つかりません(年度: {payment_date.year})") # 厚生年金(上限適用後の標準報酬月額で計算) pension_insurance_rate = PayrollCalculationService.get_insurance_rate("厚生年金", payment_date) @@ -241,15 +270,30 @@ class PayrollCalculationService: if pension_insurance_rate: pension_insurance = ( pension_standard_salary * Decimal(str(pension_insurance_rate["employee_rate"])) - ).quantize(Decimal("1")) + ).quantize(Decimal("0.01")) + print(f"[DEBUG] 厚生年金計算: {pension_standard_salary} × {pension_insurance_rate['employee_rate']}% = {pension_insurance}") + else: + errors.append(f"厚生年金保険料率が見つかりません(年度: {payment_date.year})") # 雇用保険 employment_insurance_rate = PayrollCalculationService.get_insurance_rate("雇用保険", payment_date) employment_insurance = Decimal("0") - if employment_insurance_rate: + + # 雇用保険加入対象かどうか確認 + is_employment_insurance_eligible = salary_setting.get("employment_insurance_eligible", True) if salary_setting else True + + if is_employment_insurance_eligible and employment_insurance_rate: employment_insurance = ( total_payment * Decimal(str(employment_insurance_rate["employee_rate"])) - ).quantize(Decimal("1")) + ).quantize(Decimal("0.01")) + elif not is_employment_insurance_eligible: + print(f"[DEBUG] この従業員は雇用保険非対象です") + elif not employment_insurance_rate: + errors.append(f"雇用保険料率が見つかりません(年度: {payment_date.year})") + + # エラーがある場合は、エラーメッセージを返す + if errors: + raise ValueError(f"給与計算に必要なデータが不足しています:\n" + "\n".join(errors)) # 所得税の計算 dependents_count = PayrollCalculationService.get_active_dependents_count(employee_id, payment_date) diff --git a/backend/app/payroll/employees/schemas.py b/backend/app/payroll/employees/schemas.py index a6f64a6..a6aa858 100644 --- a/backend/app/payroll/employees/schemas.py +++ b/backend/app/payroll/employees/schemas.py @@ -12,6 +12,7 @@ class DependentBase(BaseModel): name: str relationship: str # 配偶者/子/親/その他 birth_date: Optional[date] = None + mynumber: Optional[str] = None # マイナンバーカード番号 is_spouse: bool = False is_disabled: bool = False income_amount: Decimal = Decimal("0") @@ -29,6 +30,7 @@ class DependentUpdate(BaseModel): name: Optional[str] = None relationship: Optional[str] = None birth_date: Optional[date] = None + mynumber: Optional[str] = None is_spouse: Optional[bool] = None is_disabled: Optional[bool] = None income_amount: Optional[Decimal] = None diff --git a/backend/app/payroll/settings/router.py b/backend/app/payroll/settings/router.py index ba417b7..e22720a 100644 --- a/backend/app/payroll/settings/router.py +++ b/backend/app/payroll/settings/router.py @@ -31,22 +31,52 @@ def create_salary_setting(setting: schemas.SalarySettingCreate): if not cur.fetchone(): raise HTTPException(status_code=404, detail="従業員が見つかりません") + # employment_insurance_eligible カラムが存在するか確認 cur.execute( - """ - INSERT INTO salary_settings ( - employee_id, base_salary, hourly_rate, employment_type, - payment_type, commute_allowance, other_allowance, - valid_from, valid_to - ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) - RETURNING * - """, - ( - setting.employee_id, setting.base_salary, setting.hourly_rate, - setting.employment_type, setting.payment_type, - setting.commute_allowance, setting.other_allowance, - setting.valid_from, setting.valid_to - ), + "SELECT 1 FROM information_schema.columns WHERE table_name='salary_settings' AND column_name='employment_insurance_eligible'" ) + has_employment_insurance_column = cur.fetchone() + + if has_employment_insurance_column: + # カラムが存在する場合 + cur.execute( + """ + INSERT INTO salary_settings ( + employee_id, base_salary, hourly_rate, employment_type, + payment_type, commute_allowance, other_allowance, + employment_insurance_eligible, + valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + setting.employee_id, setting.base_salary, setting.hourly_rate, + setting.employment_type, setting.payment_type, + setting.commute_allowance, setting.other_allowance, + setting.employment_insurance_eligible, + setting.valid_from, setting.valid_to + ), + ) + else: + # カラムが存在しない場合(互換性維持) + cur.execute( + """ + INSERT INTO salary_settings ( + employee_id, base_salary, hourly_rate, employment_type, + payment_type, commute_allowance, other_allowance, + valid_from, valid_to + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING * + """, + ( + setting.employee_id, setting.base_salary, setting.hourly_rate, + setting.employment_type, setting.payment_type, + setting.commute_allowance, setting.other_allowance, + setting.valid_from, setting.valid_to + ), + ) + print("[DEBUG] employment_insurance_eligible カラムがまだ存在しないため、除外しました") + result = cur.fetchone() conn.commit() return result @@ -100,6 +130,25 @@ def update_salary_setting(setting_id: int, setting: schemas.SalarySettingUpdate) if not update_data: raise HTTPException(status_code=400, detail="更新するデータがありません") + # employment_insurance_eligible はまだデータベースに存在しない可能性があるため、除外 + if 'employment_insurance_eligible' in update_data: + try: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "SELECT 1 FROM information_schema.columns WHERE table_name='salary_settings' AND column_name='employment_insurance_eligible'" + ) + column_exists = cur.fetchone() + if not column_exists: + update_data.pop('employment_insurance_eligible') + print("[DEBUG] employment_insurance_eligible カラムがまだ存在しないため、除外しました") + except Exception as e: + print(f"[DEBUG] カラム確認エラー: {e}") + update_data.pop('employment_insurance_eligible', None) + + if not update_data: + raise HTTPException(status_code=400, detail="更新するデータがありません") + set_clause = ", ".join([f"{key} = %s" for key in update_data.keys()]) values = list(update_data.values()) + [setting_id] @@ -385,7 +434,9 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None): income_from = None income_to = None - for col_idx in range(min(3, sheet.ncols)): + # B列(col 1)とC列(col 2)から月収範囲を読み込む + # A列は順序番号なので、B列から開始 + for col_idx in range(1, min(3, sheet.ncols)): cell_value = sheet.cell_value(row_idx, col_idx) if cell_value: val = str(cell_value).replace(',', '').replace('円', '').strip() @@ -526,7 +577,9 @@ def import_income_tax_table(file: UploadFile = File(...), tax_year: int = None): income_from = None income_to = None - for i in range(min(3, len(row))): + # B列(col 1)とC列(col 2)から月収範囲を読み込む + # A列は順序番号なので、B列から開始 + for i in range(1, min(3, len(row))): if row[i].value: val = str(row[i].value).replace(',', '').replace('円', '').strip() try: @@ -715,6 +768,28 @@ def calculate_income_tax(monthly_income: Decimal, dependents_count: int = 0, tar } +@router.delete("/income-tax/year/{year}") +def delete_income_tax_by_year(year: int): + """指定された年度の所得税率表データをすべて削除""" + try: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM income_tax_table WHERE tax_year = %s", + (year,) + ) + deleted_count = cur.rowcount + conn.commit() + + return { + "message": f"{year}年度のデータを削除しました", + "year": year, + "deleted_count": deleted_count + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + # ======================================== # 社会保険料上限設定管理 # ======================================== diff --git a/backend/app/payroll/settings/schemas.py b/backend/app/payroll/settings/schemas.py index d94a83c..0669abe 100644 --- a/backend/app/payroll/settings/schemas.py +++ b/backend/app/payroll/settings/schemas.py @@ -16,6 +16,7 @@ class SalarySettingBase(BaseModel): payment_type: str # 月給/時給/日給 commute_allowance: Decimal = Decimal("0") other_allowance: Decimal = Decimal("0") + employment_insurance_eligible: bool = True # 雇用保険加入対象 valid_from: date valid_to: Optional[date] = None @@ -33,6 +34,7 @@ class SalarySettingUpdate(BaseModel): payment_type: Optional[str] = None commute_allowance: Optional[Decimal] = None other_allowance: Optional[Decimal] = None + employment_insurance_eligible: Optional[bool] = None valid_from: Optional[date] = None valid_to: Optional[date] = None diff --git a/backend/app/payroll/settings/standard_remuneration_router.py b/backend/app/payroll/settings/standard_remuneration_router.py index 289bb98..bd045cf 100644 --- a/backend/app/payroll/settings/standard_remuneration_router.py +++ b/backend/app/payroll/settings/standard_remuneration_router.py @@ -127,14 +127,61 @@ async def import_standard_remuneration( detail=f"A1セルから年度を抽出できません: {a1_value}" ) + # 第9行からメタデータ(保険料率)を抽出 + metadata = {} + try: + row9 = list(ws.iter_rows(min_row=9, max_row=9, values_only=True))[0] + # F9, H9, J9 (インデックス 5, 7, 9) から値を取得 + health_no_care_rate = row9[5] if len(row9) > 5 else None # F9 + health_with_care_rate = row9[7] if len(row9) > 7 else None # H9 + pension_rate = row9[9] if len(row9) > 9 else None # J9 + + print(f"[メタデータ] 第9行: F9={health_no_care_rate}, H9={health_with_care_rate}, J9={pension_rate}") + + # パーセンテージをクリーニング(文字列から%を削除) + def clean_percentage(val): + if val is None: + return None + val_str = str(val).strip() + val_str = val_str.replace('%', '').replace('%', '') + try: + return float(val_str) + except: + return None + + metadata = { + 'health_no_care_rate': clean_percentage(health_no_care_rate), + 'health_with_care_rate': clean_percentage(health_with_care_rate), + 'pension_rate': clean_percentage(pension_rate), + 'source': str(a1_value) if a1_value else None # A1セルの内容を追加 + } + print(f"[メタデータ クリーニング後] {metadata}") + except Exception as e: + print(f"[警告] メタデータ抽出エラー: {e}") + metadata = {} + # データを抽出(12行目から開始) data_rows = [] errors = [] + rows_read = [] + + # まずヘッダー行を確認(11行目) + header_row = list(ws.iter_rows(min_row=11, max_row=11, values_only=True))[0] + # ヘッダー行の全列を表示 + header_debug = f"ヘッダー(11行目): {list(header_row)}" + errors.append(header_debug) # 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行番号 + # 最初の1行だけ全列を記録(デバッグ用) + if idx == 0: + row_debug = f"Row {row_idx} (最初のデータ行): {list(row)}" + else: + row_debug = f"Row {row_idx}: {row[:10]}" + rows_read.append(row_debug) + # 等級が空の場合は終了 if not row[0]: break @@ -144,34 +191,24 @@ async def import_standard_remuneration( # 各値を取得し、Noneや空白を処理 def clean_value(val, field_name=""): - if val is None or val == '': - return None - - # 数値型の場合はそのまま返す + # 允许 '-', '-', '―', 'ー', '−', '' 视为 0 + if val is None: + return 0 if isinstance(val, (int, float)): return val - - # 文字列の場合 if isinstance(val, str): - # 空白を除去 val = val.strip() - # 空、ハイフン、全角ダッシュなどをNoneに if val in ('', '-', '-', '―', 'ー', '−'): - return None - # 「円」「未満」「以上」などの文字を除去 + return 0 val = val.replace('円', '').replace('未満', '').replace('以上', '') - # カンマを除去 val = val.replace(',', '').replace(',', '') - # 全角数字を半角に変換 val = val.translate(str.maketrans('0123456789', '0123456789')) - # 再度空チェック if val == '': - return None + return 0 return val - return val - # デバッグ用に元のデータを保存(最初の7列を確認) + # デバッグ用に元のデータとclean_value结果を保存 raw_data = { 'col0_grade': row[0] if len(row) > 0 else None, 'col1': row[1] if len(row) > 1 else None, @@ -182,6 +219,18 @@ async def import_standard_remuneration( 'col6': row[6] if len(row) > 6 else None, 'col7': row[7] if len(row) > 7 else None, } + # 记录清洗后的值 + cleaned_data = { + 'grade': clean_value(row[0]) if len(row) > 0 else None, + 'monthly_amount': clean_value(row[1]) if len(row) > 1 else None, + 'salary_from': clean_value(row[2]) if len(row) > 2 else None, + 'salary_to': clean_value(row[3]) if len(row) > 3 else None, + 'health_no_care': clean_value(row[4]) if len(row) > 4 else None, + 'health_with_care': clean_value(row[5]) if len(row) > 5 else None, + 'pension': clean_value(row[6]) if len(row) > 6 else None, + } + # 追加调试信息 + errors.append(f"行{row_idx}: 原始数据={raw_data}, 清洗後={cleaned_data}") # 報酬月額範囲の処理 # 複数のパターンに対応: @@ -191,45 +240,51 @@ async def import_standard_remuneration( monthly_amount = clean_value(row[1], 'monthly_amount') - # 列3が「~」かチェック + # 列2と列3の内容をチェック + col2_raw = str(row[2]) if len(row) > 2 and row[2] is not None else '' col3_raw = str(row[3]) if len(row) > 3 and row[3] is not None else '' + col2_cleaned = col2_raw.strip() col3_cleaned = col3_raw.strip() - # 列2に範囲が含まれているかチェック - col2_raw = str(row[2]) if len(row) > 2 and row[2] is not None else '' + # パターン判定: + # パターンA: col2 = '~', col3 = 数値 → [grade, monthly, ~, range_to, health_no_full, health_no_half, health_with_full, health_with_half, ...] + # パターンB: col2 = 数値, col3 = '~' → [grade, monthly, range_from, ~, range_to, health_no_full, health_no_half, health_with_full, health_with_half, ...] + # パターンC: col2 = 数値, col3 = 数値 → [grade, monthly, range_from, range_to, health_no_full, health_no_half, health_with_full, health_with_half, ...] - if col3_cleaned in ('~', '〜', '-'): + if col2_cleaned in ('~', '〜', '-'): + # パターンA: 列2=「~」, 列3=上限値 + salary_from = 0 # または前の行から取得 + salary_to = clean_value(row[3], 'salary_to') + # 保険料は列4以降 + health_no_care = clean_value(row[4] if len(row) > 4 else None, 'health_no_care') + health_with_care = clean_value(row[6] if len(row) > 6 else None, 'health_with_care') + + # デバッグ: row全体の長さとrow[8]の値を記録 + print(f"[デバッグ パターンA] 行{row_idx}: len(row)={len(row)}, row全体={list(row)}") + print(f" row[8]={row[8] if len(row) > 8 else 'N/A (行が短い)'}") + + pension = clean_value(row[8] if len(row) > 8 else None, 'pension') + if pension == 0: + print(f" ⚠️ 警告: ペンション値が0です。row[8]の元データ = {row[8] if len(row) > 8 else 'N/A'}") + print(f" clean_value後: pension={pension}, type={type(pension)}") + + elif 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') + health_with_care = clean_value(row[7] if len(row) > 7 else None, 'health_with_care') + pension = clean_value(row[9] if len(row) > 9 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=上限の通常パターン + # パターン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') + salary_to = clean_value(row[3], 'salary_to') + # 保険料は列4以降 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') + health_with_care = clean_value(row[6] if len(row) > 6 else None, 'health_with_care') + pension = clean_value(row[8] if len(row) > 8 else None, 'pension') # 必須フィールドのチェック(詳細なエラーメッセージ) missing_fields = [] @@ -298,6 +353,8 @@ async def import_standard_remuneration( error_msg = "有効なデータが見つかりませんでした" if errors: error_msg += "\n\nエラー詳細:\n" + "\n".join(errors[:15]) + if rows_read: + error_msg += "\n\n読み込んだ行:\n" + "\n".join(rows_read[:10]) raise HTTPException(status_code=400, detail=error_msg) # データベースに保存 @@ -326,13 +383,25 @@ async def import_standard_remuneration( "year": rate_year, "prefecture": prefecture, "imported_count": imported_count, - "source": a1_value + "source": a1_value, + "metadata": metadata, + "debug_info": { + "total_rows_read": len(rows_read), + "first_rows_sample": rows_read[:3], + "a1_value_debug": f"A1セル内容: {a1_value}" + } } except ValueError as e: - raise HTTPException(status_code=400, detail="年度は数値で入力してください") + raise HTTPException(status_code=400, detail=f"年度は数値で入力してください: {str(e)}") except Exception as e: - raise HTTPException(status_code=500, detail=str(e)) + error_detail = { + "error": str(e), + "error_type": type(e).__name__, + "rows_read_count": len(rows_read) if 'rows_read' in locals() else 0, + "first_rows_sample": rows_read[:3] if 'rows_read' in locals() else [] + } + raise HTTPException(status_code=500, detail=error_detail) @router.delete("") @@ -356,3 +425,43 @@ async def delete_standard_remuneration(year: int, prefecture: str): } except Exception as e: raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/all") +async def delete_all_standard_remuneration(): + """すべての標準報酬月額表データを削除""" + try: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute("DELETE FROM standard_remuneration") + deleted_count = cur.rowcount + conn.commit() + + return { + "message": "すべてのデータを削除しました", + "deleted_count": deleted_count + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@router.delete("/year/{year}") +async def delete_standard_remuneration_by_year(year: int): + """指定された年度の標準報酬月額表データをすべて削除""" + try: + with get_connection() as conn: + with conn.cursor() as cur: + cur.execute( + "DELETE FROM standard_remuneration WHERE rate_year = %s", + (year,) + ) + deleted_count = cur.rowcount + conn.commit() + + return { + "message": f"{year}年度のデータを削除しました", + "year": year, + "deleted_count": deleted_count + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/backend/sql/add_employment_insurance_eligible.sql b/backend/sql/add_employment_insurance_eligible.sql new file mode 100644 index 0000000..f9c23e2 --- /dev/null +++ b/backend/sql/add_employment_insurance_eligible.sql @@ -0,0 +1,5 @@ +-- 雇用保険加入対象フラグを salary_settings テーブルに追加 +ALTER TABLE salary_settings +ADD COLUMN IF NOT EXISTS employment_insurance_eligible BOOLEAN DEFAULT TRUE; + +COMMENT ON COLUMN salary_settings.employment_insurance_eligible IS '雇用保険加入対象: true=対象、false=非対象'; diff --git a/backend/sql/add_mynumber_to_dependents.sql b/backend/sql/add_mynumber_to_dependents.sql new file mode 100644 index 0000000..a11cde7 --- /dev/null +++ b/backend/sql/add_mynumber_to_dependents.sql @@ -0,0 +1,4 @@ +-- マイナンバーカード番号カラムを扶養家族テーブルに追加 +ALTER TABLE dependents ADD COLUMN IF NOT EXISTS mynumber VARCHAR(12); + +COMMENT ON COLUMN dependents.mynumber IS 'マイナンバーカード番号(12桁)'; diff --git a/backend/sql/payroll_schema.sql b/backend/sql/payroll_schema.sql index 8c5e516..41e6420 100644 --- a/backend/sql/payroll_schema.sql +++ b/backend/sql/payroll_schema.sql @@ -68,6 +68,7 @@ CREATE TABLE IF NOT EXISTS salary_settings ( payment_type VARCHAR(20) NOT NULL, -- 月給/時給/日給 commute_allowance DECIMAL(12, 2) DEFAULT 0, other_allowance DECIMAL(12, 2) DEFAULT 0, + employment_insurance_eligible BOOLEAN DEFAULT TRUE, -- 雇用保険加入対象かどうか valid_from DATE NOT NULL, valid_to DATE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, @@ -78,6 +79,7 @@ CREATE TABLE IF NOT EXISTS salary_settings ( COMMENT ON TABLE salary_settings IS '給与基本設定'; COMMENT ON COLUMN salary_settings.employment_type IS '雇用形態: 正社員/契約社員/パート/アルバイト'; COMMENT ON COLUMN salary_settings.payment_type IS '支給形態: 月給/時給/日給'; +COMMENT ON COLUMN salary_settings.employment_insurance_eligible IS '雇用保険加入対象: true=対象、false=非対象'; -- ======================================== -- 4. 社会保険料率マスタ (Insurance Rates) diff --git a/frontend/payroll-calculation.html b/frontend/payroll-calculation.html index d0951fc..74c758d 100644 --- a/frontend/payroll-calculation.html +++ b/frontend/payroll-calculation.html @@ -88,6 +88,14 @@ .payroll-detail { margin-top: 20px; } + /* 明細を中央に寄せ、横幅を狭める(読みやすくするため) */ + .payroll-detail { + max-width: 760px; + margin-left: auto; + margin-right: auto; + padding-left: 8px; + padding-right: 8px; + } .detail-section { margin-bottom: 20px; padding: 15px; @@ -118,157 +126,371 @@ .back-link:hover { text-decoration: underline; } + /* タブスタイル */ + .nav-tabs-calc { + display: flex; + gap: 10px; + margin-bottom: 20px; + border-bottom: 2px solid #ddd; + } + .nav-tab-calc { + padding: 10px 20px; + cursor: pointer; + border: none; + background: #f0f0f0; + border-radius: 5px 5px 0 0; + } + .nav-tab-calc.active { + background: #007bff; + color: white; + } + .tab-content-calc { + display: none; + } + .tab-content-calc.active { + display: block; + }
← 給与管理メニューに戻る -

給与管理 - 月次給与計算

+

給与管理 - 給与・賞与計算

-
- - - - +
-
+ +
+
+ + + +
- -
-

給与計算

-
-
- - -
+
-
+ +
+

給与計算

+
- - + +
-
- - -
-
- - -
-
-
-

勤怠情報

- - -
-
- +
- + + +
+
+
-
-
- - -
-
- - -
-
- - -
-
-
-
-

その他

+
+

勤怠情報

+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+

その他

+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + +
+ + + +
+ + +
+
+ + + +
+ +
+ + +
+

賞与計算

+
+
+ + +
+
- + + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+
+
+ +
+
+ + +
-
- - - + + + +
+ + +
- - -
diff --git a/frontend/payroll-employees.html b/frontend/payroll-employees.html index fe071a3..05dae44 100644 --- a/frontend/payroll-employees.html +++ b/frontend/payroll-employees.html @@ -916,7 +916,7 @@ employee.employee_id })" style="padding: 5px 10px; font-size: 12px;">扶養家族追加 ${(() => { - // 源泉税控除対象人数を計算(19歳以上または70歳以上) + // 源泉税控除対象人数を計算(16歳以上) let koureiCount = 0; if ( employee.dependents && @@ -926,10 +926,11 @@ const taxStatus = getTaxDeductionStatus( dep.birth_date ); - // 源泉税控除対象は19歳以上(一般扶養親族16-18歳は住民税のみなので除外) + // 源泉税控除対象は16歳以上(一般扶養親族16-18歳、特定扶養親族19-22歳、老人扶養親族70歳以上) return ( taxStatus?.isDeductible && - (taxStatus.type === "控除対象扶養親族" || + (taxStatus.type === "一般扶養親族" || + taxStatus.type === "控除対象扶養親族" || taxStatus.type === "老人扶養親族") ); }).length; @@ -939,7 +940,7 @@
甲欄扶養親族人数(源泉税控除対象): ${koureiCount}人 - ※19歳以上の扶養親族が対象(16-18歳は住民税のみ対象のため含まれません) + ※16歳以上の扶養親族が対象(一般扶養親族16-18歳、特定扶養親族19-22歳、老人扶養親族70歳以上)
`; @@ -962,7 +963,7 @@ : "" } ${ - taxStatus?.isDeductible + taxStatus?.label ? ` [${taxStatus.label}]` : "" } @@ -1004,7 +1005,8 @@ ); return ( taxStatus?.isDeductible && - (taxStatus.type === "控除対象扶養親族" || + (taxStatus.type === "一般扶養親族" || + taxStatus.type === "控除対象扶養親族" || taxStatus.type === "老人扶養親族") ); }).length; @@ -1015,8 +1017,8 @@ 📋 源泉税控除の説明:

@@ -1253,6 +1255,11 @@

+
+ + + 12桁の数字(任意入力) +
+
+ + + 12桁の数字(任意入力) +
+
+ + チェックを入れると雇用保険に加入します +
支給形態 通勤手当 その他手当 + 雇用保険 適用期間 操作 @@ -290,6 +304,11 @@ ${s.payment_type} ¥${Number(s.commute_allowance).toLocaleString()} ¥${Number(s.other_allowance).toLocaleString()} + ${ + s.employment_insurance_eligible !== false + ? "✓ 対象" + : "✗ 非対象" + } ${s.valid_from} ~ ${s.valid_to || "現在"}
@@ -635,7 +634,399 @@ } else if (tabName === "tax") { loadIncomeTax(); } else if (tabName === "standard-remuneration") { - loadStdRemunerationData(); + loadStdRemunerationYears(); + } + } + + // ===================================================== + // 標準報酬月額表関連 + // ===================================================== + async function loadStdRemunerationYears() { + try { + const response = await fetch( + `${API_BASE}/payroll/settings/standard-remuneration/years` + ); + const years = await response.json(); + + if (!years || years.length === 0) { + document.getElementById("stdRemunerationYearsList").innerHTML = + "

データがありません。インポートしてください。

"; + return; + } + + const html = years + .sort((a, b) => b - a) + .map( + (year) => ` +
+
+
+ 📅 ${year}年度 (クリックして展開) +
+ +
+
+
読み込み中...
+
+
+ ` + ) + .join(""); + + document.getElementById("stdRemunerationYearsList").innerHTML = html; + } catch (error) { + console.error("年度リストの取得に失敗:", error); + document.getElementById("stdRemunerationYearsList").innerHTML = + '

年度リストの取得に失敗しました

'; + } + } + + async function toggleStdRemunerationYear(year) { + const yearItem = document.getElementById(`std-year-${year}`); + const wasExpanded = yearItem.classList.contains("expanded"); + + // 他の年度を閉じる + document + .querySelectorAll("#stdRemunerationYearsList .year-item") + .forEach((item) => { + item.classList.remove("expanded"); + }); + + if (!wasExpanded) { + yearItem.classList.add("expanded"); + currentExpandedStdYear = year; + await loadStdRemunerationYearData(year); + } else { + currentExpandedStdYear = null; + } + } + + async function loadStdRemunerationYearData(year) { + const params = new URLSearchParams(); + params.append("year", year); + const url = `${API_BASE}/payroll/settings/standard-remuneration?${params.toString()}`; + + try { + const response = await fetch(url); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + + const data = await response.json(); + + if (!Array.isArray(data) || data.length === 0) { + document.getElementById(`std-table-${year}`).innerHTML = + "

該当するデータがありません

"; + return; + } + + // セッションストレージからメタデータを取得 + const metadata = JSON.parse( + sessionStorage.getItem("stdRemunMetadata") || "{}" + ); + console.log("取得したメタデータ:", metadata); + + // 都道府県ごとにグループ化 + const prefectures = {}; + data.forEach((row) => { + if (!prefectures[row.prefecture]) { + prefectures[row.prefecture] = []; + } + prefectures[row.prefecture].push(row); + }); + + // 都道府県ごとにテーブルを作成 + let tablesHtml = ""; + for (const [prefecture, prefData] of Object.entries(prefectures)) { + tablesHtml += `

${prefecture}

`; + tablesHtml += renderStdRemunerationTable(prefData, metadata); + } + + document.getElementById(`std-table-${year}`).innerHTML = tablesHtml; + } catch (error) { + console.error("データ読み込みエラー:", error); + document.getElementById( + `std-table-${year}` + ).innerHTML = `

データの取得に失敗しました: ${error.message}

`; + } + } + + function renderStdRemunerationTable(data, metadata = {}) { + if (!data || data.length === 0) { + return "

データがありません。

"; + } + + // A1セルの内容を表示(あれば) + let sourceHtml = ""; + console.log("renderStdRemunerationTable - metadata内容:", metadata); + console.log( + "renderStdRemunerationTable - metadata.source:", + metadata.source + ); + if (metadata.source) { + sourceHtml = `

対象期間: ${metadata.source}

`; + } else { + console.warn("⚠️ metadata.sourceが空です"); + } + + // メタデータから保険料率を取得 + const rates = { + healthNoCareRate: metadata.health_no_care_rate, + healthWithCareRate: metadata.health_with_care_rate, + pensionRate: metadata.pension_rate, + }; + + // メタデータはヘッダーに組み込むため、ここでは空にする + let metadataHtml = ""; + + // 等級でソート(最初の数字を抽出して数値順にソート) + const sortedData = data.sort((a, b) => { + const gradeA = String(a.grade).trim(); + const gradeB = String(b.grade).trim(); + + // 括弧や記号を除いて、最初の数字を抽出 + const numA = parseInt(gradeA.match(/\d+/)?.[0] || 0); + const numB = parseInt(gradeB.match(/\d+/)?.[0] || 0); + + // 数値で比較 + if (numA !== numB) { + return numA - numB; + } + + // 同じ数値の場合は元の文字列で比較 + return gradeA.localeCompare(gradeB, "ja"); + }); + + // ペンション値の段階的な埋め充(2段階) + // ステップ1: 0の値に対して、下方を探して最初の非ゼロ値を使用(向下查找) + const step1Data = [...sortedData]; + for (let i = 0; i < step1Data.length; i++) { + const pensionVal = parseFloat(step1Data[i].pension_insurance); + if (pensionVal === 0) { + // 下方を探して最初の非ゼロ値を見つける + let nextNonZero = 0; + for (let j = i + 1; j < step1Data.length; j++) { + const nextVal = parseFloat(step1Data[j].pension_insurance); + if (nextVal > 0) { + nextNonZero = nextVal; + break; + } + } + if (nextNonZero > 0) { + step1Data[i].pension_insurance = nextNonZero; + } + } + } + + console.log("=== ステップ1完了(下方探索)==="); + console.log( + "step1Data:", + step1Data.map((d) => ({ + grade: d.grade, + pension: d.pension_insurance, + })) + ); + + // ステップ2: 非ゼロ値を下方へ延続(前の有効値が0以下になったら、前の非ゼロ値を使用) + const adjustedData = [...step1Data]; + let lastValidPension = 0; + + for (let i = 0; i < adjustedData.length; i++) { + const pensionVal = parseFloat(adjustedData[i].pension_insurance); + + if (pensionVal > 0) { + // 非ゼロ値を見つけたら、それを記録 + lastValidPension = pensionVal; + } else if (pensionVal === 0 && lastValidPension > 0) { + // 0だが前に有効な値がある場合、それを使用 + adjustedData[i].pension_insurance = lastValidPension; + } + } + + console.log("=== ステップ2完了(下方延続)==="); + console.log( + "adjustedData:", + adjustedData.map((d) => ({ + grade: d.grade, + pension: d.pension_insurance, + })) + ); + + console.log("調整後のデータ(最初の3件):", adjustedData.slice(0, 3)); + + const thead = ` + + + 等級 + 標準報酬月額 + 報酬月額 以上 + 報酬月額 未満 + 健康保険料(介護保険なし) + 健康保険料(介護保険あり) + 厚生年金保険料 + + + + ${ + rates.healthNoCareRate !== undefined + ? rates.healthNoCareRate.toFixed(2) + "%" + : "-" + } + + + ${ + rates.healthWithCareRate !== undefined + ? rates.healthWithCareRate.toFixed(2) + "%" + : "-" + } + + + ${ + rates.pensionRate !== undefined + ? rates.pensionRate.toFixed(2) + "%" + : "-" + } + + + + 全額 + 折半額 + 全額 + 折半額 + 全額 + 折半額 + + + `; + const tbody = ` + + ${adjustedData + .map((row) => { + // 保険料を2で割って折半額を計算 + const healthNoCareHalf = ( + parseFloat(row.health_insurance_no_care) / 2 + ).toFixed(2); + const healthWithCareHalf = ( + parseFloat(row.health_insurance_with_care) / 2 + ).toFixed(2); + const pensionHalf = ( + parseFloat(row.pension_insurance) / 2 + ).toFixed(2); + + return ` + + ${row.grade ?? ""} + ${Number(row.monthly_amount ?? 0).toLocaleString()} + ${Number(row.salary_from ?? 0).toLocaleString()} + ${Number(row.salary_to ?? 0).toLocaleString()} + ${Number( + row.health_insurance_no_care ?? 0 + ).toLocaleString()} + ${Number(healthNoCareHalf ?? 0).toLocaleString()} + ${Number( + row.health_insurance_with_care ?? 0 + ).toLocaleString()} + ${Number(healthWithCareHalf ?? 0).toLocaleString()} + ${Number( + row.pension_insurance ?? 0 + ).toLocaleString()} + ${Number(pensionHalf ?? 0).toLocaleString()} + + `; + }) + .join("")} + + `; + return ( + sourceHtml + + metadataHtml + + `${thead}${tbody}
` + ); + } + + async function loadStdRemunerationData() { + // 现在这个函数用于在导入后刷新年份列表 + await loadStdRemunerationYears(); + } + + async function deleteStdRemunerationYear(year) { + const confirmed = confirm( + `⚠️ 警告\n\n${year}年度の標準報酬月額表データを削除します。\nこの操作は取り消せません。\n\n本当に削除してもよろしいですか?` + ); + if (!confirmed) { + console.log(`${year}年度の削除がキャンセルされました`); + return; + } + + console.log(`=== ${year}年度データ削除開始 ===`); + + try { + const response = await fetch( + `${API_BASE}/payroll/settings/standard-remuneration/year/${year}`, + { method: "DELETE" } + ); + + console.log("削除レスポンスステータス:", response.status); + const result = await response.json(); + console.log("削除結果:", result); + + if (response.ok) { + alert( + `✓ ${year}年度のデータ(${result.deleted_count}件)を削除しました` + ); + console.log(`=== ${year}年度データ削除完了 ===`); + + // 年份リストをリロード + await loadStdRemunerationYears(); + } else { + alert( + "削除に失敗しました:\n" + JSON.stringify(result.detail || result) + ); + } + } catch (error) { + console.error("削除エラー:", error); + alert(`${year}年度のデータ削除に失敗しました:\n` + error.message); + } + } + + async function clearAllStdRemunerationData() { + const confirmed = confirm( + "⚠️ 警告\n\n標準報酬月額表のすべてのデータを削除します。\nこの操作は取り消せません。\n\n本当に削除してもよろしいですか?" + ); + if (!confirmed) { + console.log("削除操作がキャンセルされました"); + return; + } + + console.log("=== 全データ削除開始 ==="); + + try { + const response = await fetch( + `${API_BASE}/payroll/settings/standard-remuneration/all`, + { method: "DELETE" } + ); + + console.log("削除レスポンスステータス:", response.status); + const result = await response.json(); + console.log("削除結果:", result); + + if (response.ok) { + alert( + `✓ ${result.deleted_count}件のデータを削除しました\n\n再度Excelファイルをインポートしてください` + ); + console.log("=== 全データ削除完了 ==="); + + // 年份列表をクリア + document.getElementById("stdRemunerationYearsList").innerHTML = + "

データが削除されました。Excelファイルを再度インポートしてください。

"; + } else { + alert( + "削除に失敗しました:\n" + JSON.stringify(result.detail || result) + ); + } + } catch (error) { + console.error("削除エラー:", error); + alert("全データ削除中にエラーが発生しました:\n" + error.message); } } @@ -643,7 +1034,12 @@ // 保険料率関連 // ===================================================== function showInsuranceForm() { - document.getElementById("insuranceForm").style.display = "block"; + const el = document.getElementById("insuranceForm"); + el.style.display = "block"; + // 表示エリアへスクロールして最初の入力にフォーカス + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const firstInput = el.querySelector('[name="rate_year"]'); + if (firstInput) firstInput.focus(); } function hideInsuranceForm() { @@ -719,6 +1115,9 @@ } ${rate.notes || "-"} + @@ -761,30 +1160,72 @@ document.getElementById("editRateId").value = rateId; // フォームにデータを入力 - const form = document.getElementById("insuranceRateForm"); - form.querySelector('[name="rate_year"]').value = rate.rate_year; - form.querySelector('[name="calculation_target"]').value = - rate.calculation_target; - form.querySelector('[name="rate_type"]').value = rate.rate_type; - form.querySelector('[name="prefecture"]').value = rate.prefecture; - form.querySelector('[name="employee_rate"]').value = ( - rate.employee_rate * 100 - ).toFixed(3); - form.querySelector('[name="employer_rate"]').value = ( - rate.employer_rate * 100 - ).toFixed(3); - form.querySelector('[name="care_insurance_age_threshold"]').value = - rate.care_insurance_age_threshold || ""; - form.querySelector('[name="notes"]').value = rate.notes || ""; + fillInsuranceForm(rate); // フォームを表示 - document.getElementById("insuranceForm").style.display = "block"; + const el = document.getElementById("insuranceForm"); + el.style.display = "block"; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const firstInput = el.querySelector('[name="rate_year"]'); + if (firstInput) firstInput.focus(); } catch (error) { alert("データの取得に失敗しました"); console.error(error); } } + async function copyInsuranceRate(rateId) { + try { + const response = await fetch( + `${API_BASE}/payroll/settings/insurance-rates` + ); + const rates = await response.json(); + const rate = rates.find((r) => r.rate_id === rateId); + + if (!rate) { + alert("データが見つかりません"); + return; + } + + // フォームを新規追加モードに切り替え + document.getElementById("insuranceFormTitle").innerText = + "保険料率の登録(複製)"; + document.getElementById("editMode").value = "false"; + document.getElementById("editRateId").value = ""; + + // フォームにデータを入力 + fillInsuranceForm(rate); + + // フォームを表示 + const el = document.getElementById("insuranceForm"); + el.style.display = "block"; + el.scrollIntoView({ behavior: "smooth", block: "center" }); + const firstInput = el.querySelector('[name="rate_year"]'); + if (firstInput) firstInput.focus(); + } catch (error) { + alert("データの取得に失敗しました"); + console.error(error); + } + } + + function fillInsuranceForm(rate) { + const form = document.getElementById("insuranceRateForm"); + form.querySelector('[name="rate_year"]').value = rate.rate_year; + form.querySelector('[name="calculation_target"]').value = + rate.calculation_target; + form.querySelector('[name="rate_type"]').value = rate.rate_type; + form.querySelector('[name="prefecture"]').value = rate.prefecture; + form.querySelector('[name="employee_rate"]').value = ( + rate.employee_rate * 100 + ).toFixed(3); + form.querySelector('[name="employer_rate"]').value = ( + rate.employer_rate * 100 + ).toFixed(3); + form.querySelector('[name="care_insurance_age_threshold"]').value = + rate.care_insurance_age_threshold || ""; + form.querySelector('[name="notes"]').value = rate.notes || ""; + } + async function saveInsuranceRate(event) { event.preventDefault(); const form = event.target; @@ -1275,6 +1716,7 @@ // 所得税率表関連 // ===================================================== let currentExpandedYear = null; + let currentExpandedStdYear = null; async function loadIncomeTax() { await loadIncomeTaxYears(); @@ -1296,14 +1738,19 @@ const html = data.years .map( (year) => ` -
-
- 📅 ${year}年度 (クリックして展開) +
+
+
+ 📅 ${year}年度 (クリックして展開) +
+
- @@ -1329,6 +1776,46 @@ } } + async function deleteTaxDataYear(year) { + const confirmed = confirm( + `⚠️ 警告\n\n${year}年度の所得税率表データを削除します。\nこの操作は取り消せません。\n\n本当に削除してもよろしいですか?` + ); + if (!confirmed) { + console.log(`${year}年度の削除がキャンセルされました`); + return; + } + + console.log(`=== ${year}年度所得税データ削除開始 ===`); + + try { + const response = await fetch( + `${API_BASE}/payroll/settings/income-tax/year/${year}`, + { method: "DELETE" } + ); + + console.log("削除レスポンスステータス:", response.status); + const result = await response.json(); + console.log("削除結果:", result); + + if (response.ok) { + alert( + `✓ ${year}年度のデータ(${result.deleted_count}件)を削除しました` + ); + console.log(`=== ${year}年度所得税データ削除完了 ===`); + + // 年份リストをリロード + await loadIncomeTaxYears(); + } else { + alert( + "削除に失敗しました:\n" + JSON.stringify(result.detail || result) + ); + } + } catch (error) { + console.error("削除エラー:", error); + alert(`${year}年度のデータ削除に失敗しました:\n` + error.message); + } + } + async function toggleYear(year) { const yearItem = document.getElementById(`year-${year}`); const wasExpanded = yearItem.classList.contains("expanded"); @@ -1445,181 +1932,19 @@ } // ===================================================== - // 標準報酬月額表関連の関数 + // 標準報酬月額表関連 // ===================================================== - 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 = - "

データがありません。Excelファイルをインポートしてください。

"; - return; - } - - const html = years - .map( - (year) => ` -
-
- ${year}年度 - -
-
-
- ` - ) - .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 = - '

データがありません

'; - 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 = ` -
- - -
- ${Object.entries(groupedByPrefecture) - .map( - ([prefecture, rows]) => ` -
-

${prefecture}

- - - - - - - - - - - - - - - - - - ${rows - .map( - (row) => ` - - - - - - - - - - ` - ) - .join("")} - -
等級標準報酬月額報酬月額健康保険料厚生年金保険料
以上未満介護保険なし介護保険あり
${row.grade}${formatNumber( - row.monthly_amount - )}${formatNumber(row.salary_from)}${ - row.salary_to ? formatNumber(row.salary_to) : "〜" - }${formatNumber( - row.health_insurance_no_care - )}${formatNumber( - row.health_insurance_with_care - )}${formatNumber( - row.pension_insurance - )}
-
- ` - ) - .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("都道府県名を入力してください"); + alert("都道府県を入力してください"); event.target.value = ""; return; } @@ -1628,6 +1953,10 @@ formData.append("file", file); formData.append("prefecture", prefecture.trim()); + console.log("=== インポート開始 ==="); + console.log("ファイル:", file.name); + console.log("都道府県:", prefecture.trim()); + try { const response = await fetch( `${API_BASE}/payroll/settings/standard-remuneration/import`, @@ -1637,31 +1966,59 @@ } ); + console.log("レスポンスステータス:", response.status); + const result = await response.json(); + console.log("レスポンスボディ:", result); + if (response.ok) { - const result = await response.json(); - alert( - `インポートしました: ${result.imported_count}件\n年度: ${ - result.year - }年\n都道府県: ${prefecture}\n\n取得元: ${result.source || ""}` - ); + let message = `✓ インポート成功!\n年度: ${result.year}年\n都道府県: ${prefecture}\nインポート件数: ${result.imported_count}件`; + if (result.source) { + message += `\n取得元: ${result.source}`; + } + console.log("インポート成功:", message); + console.log("デバッグ情報:", result.debug_info); + + // メタデータをセッションストレージに保存 + if (result.metadata) { + console.log("メタデータ保存:", result.metadata); + sessionStorage.setItem( + "stdRemunMetadata", + JSON.stringify(result.metadata) + ); + } + + alert(message); + // 年度リストをリロード(新UIは検索条件がないため、直接リロード) loadStdRemunerationData(); } else { - const error = await response.json(); - alert("インポートに失敗しました: " + JSON.stringify(error.detail)); + // エラーレスポンスの詳細を表示 + const errorDetail = result.detail; + console.error("インポート失敗:", errorDetail); + + // エラー詳細が配列またはオブジェクトの場合 + let errorMessage = "インポートに失敗しました:\n\n"; + if (Array.isArray(errorDetail)) { + errorMessage += errorDetail.slice(0, 10).join("\n"); + } else if (typeof errorDetail === "object") { + errorMessage += JSON.stringify(errorDetail, null, 2).substring( + 0, + 1000 + ); + } else { + errorMessage += String(errorDetail).substring(0, 500); + } + + console.error("詳細エラー:", errorMessage); + alert(errorMessage); } } catch (error) { - alert("インポートに失敗しました"); - console.error(error); + console.error("例外発生:", error); + alert("インポートに失敗しました: " + error.message); } event.target.value = ""; } - function formatNumber(num) { - if (num === null || num === undefined) return "-"; - return Number(num).toLocaleString("ja-JP"); - } - // ===================================================== // 初期化 // =====================================================