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; + }