修改
This commit is contained in:
@@ -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}")
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
"""賞与を削除"""
|
||||
|
||||
@@ -58,12 +58,15 @@ class BonusCalculationService:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT AVG(total_payment - commute_allowance) as avg_salary
|
||||
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)
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -31,6 +31,34 @@ def create_salary_setting(setting: schemas.SalarySettingCreate):
|
||||
if not cur.fetchone():
|
||||
raise HTTPException(status_code=404, detail="従業員が見つかりません")
|
||||
|
||||
# employment_insurance_eligible カラムが存在するか確認
|
||||
cur.execute(
|
||||
"SELECT 1 FROM information_schema.columns WHERE table_name='salary_settings' AND column_name='employment_insurance_eligible'"
|
||||
)
|
||||
has_employment_insurance_column = cur.fetchone()
|
||||
|
||||
if has_employment_insurance_column:
|
||||
# カラムが存在する場合
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO salary_settings (
|
||||
employee_id, base_salary, hourly_rate, employment_type,
|
||||
payment_type, commute_allowance, other_allowance,
|
||||
employment_insurance_eligible,
|
||||
valid_from, valid_to
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING *
|
||||
""",
|
||||
(
|
||||
setting.employee_id, setting.base_salary, setting.hourly_rate,
|
||||
setting.employment_type, setting.payment_type,
|
||||
setting.commute_allowance, setting.other_allowance,
|
||||
setting.employment_insurance_eligible,
|
||||
setting.valid_from, setting.valid_to
|
||||
),
|
||||
)
|
||||
else:
|
||||
# カラムが存在しない場合(互換性維持)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO salary_settings (
|
||||
@@ -47,6 +75,8 @@ def create_salary_setting(setting: schemas.SalarySettingCreate):
|
||||
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))
|
||||
|
||||
|
||||
# ========================================
|
||||
# 社会保険料上限設定管理
|
||||
# ========================================
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
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:
|
||||
# パターンC: 列2=下限、列3=上限の数値パターン
|
||||
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')
|
||||
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))
|
||||
|
||||
5
backend/sql/add_employment_insurance_eligible.sql
Normal file
5
backend/sql/add_employment_insurance_eligible.sql
Normal file
@@ -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=非対象';
|
||||
4
backend/sql/add_mynumber_to_dependents.sql
Normal file
4
backend/sql/add_mynumber_to_dependents.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- マイナンバーカード番号カラムを扶養家族テーブルに追加
|
||||
ALTER TABLE dependents ADD COLUMN IF NOT EXISTS mynumber VARCHAR(12);
|
||||
|
||||
COMMENT ON COLUMN dependents.mynumber IS 'マイナンバーカード番号(12桁)';
|
||||
@@ -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)
|
||||
|
||||
@@ -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,32 +126,57 @@
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<a href="payroll.html" class="back-link">← 給与管理メニューに戻る</a>
|
||||
|
||||
<h1>給与管理 - 月次給与計算</h1>
|
||||
<h1>給与管理 - 給与・賞与計算</h1>
|
||||
|
||||
<div class="nav-tabs-calc">
|
||||
<button class="nav-tab-calc active" onclick="switchTab('salary')">
|
||||
給与計算
|
||||
</button>
|
||||
<button class="nav-tab-calc" onclick="switchTab('bonus')">
|
||||
賞与計算
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 給与計算タブ -->
|
||||
<div id="tab-salary" class="tab-content-calc active">
|
||||
<div class="filters">
|
||||
<label>
|
||||
対象年月:
|
||||
<input
|
||||
type="number"
|
||||
id="filterYear"
|
||||
style="width: 100px"
|
||||
placeholder="年"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="number"
|
||||
id="filterMonth"
|
||||
style="width: 80px"
|
||||
min="1"
|
||||
max="12"
|
||||
placeholder="月"
|
||||
type="month"
|
||||
id="filterYearMonth"
|
||||
style="width: 160px"
|
||||
placeholder="YYYY-MM"
|
||||
/>
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="loadPayrolls()">検索</button>
|
||||
@@ -186,17 +219,33 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>対象年 *</label>
|
||||
<input type="number" name="payroll_year" required />
|
||||
<input
|
||||
type="text"
|
||||
name="payroll_year"
|
||||
inputmode="numeric"
|
||||
pattern="\d{4}"
|
||||
maxlength="4"
|
||||
oninput="sanitizeDigits(this,4)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>対象月 *</label>
|
||||
<input
|
||||
type="number"
|
||||
name="payroll_month"
|
||||
min="1"
|
||||
max="12"
|
||||
required
|
||||
/>
|
||||
<select name="payroll_month" required>
|
||||
<option value="">選択してください</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>支給日 *</label>
|
||||
@@ -215,7 +264,12 @@
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>出勤日数</label>
|
||||
<input type="number" name="working_days" step="0.5" value="0" />
|
||||
<input
|
||||
type="number"
|
||||
name="working_days"
|
||||
step="0.5"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>勤務時間</label>
|
||||
@@ -252,7 +306,12 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>欠勤日数</label>
|
||||
<input type="number" name="absent_days" step="0.5" value="0" />
|
||||
<input
|
||||
type="number"
|
||||
name="absent_days"
|
||||
step="0.5"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,8 +368,209 @@
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- 賞与計算タブ -->
|
||||
<div id="tab-bonus" class="tab-content-calc">
|
||||
<div class="filters">
|
||||
<label>
|
||||
対象年月:
|
||||
<input
|
||||
type="month"
|
||||
id="filterBonusYearMonth"
|
||||
style="width: 160px"
|
||||
placeholder="YYYY-MM"
|
||||
/>
|
||||
</label>
|
||||
<button class="btn btn-primary" onclick="loadBonus()">検索</button>
|
||||
<button class="btn btn-success" onclick="showBonusForm()">
|
||||
新規計算
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="bonusList" class="payroll-list"></div>
|
||||
|
||||
<!-- 賞与計算フォーム -->
|
||||
<div
|
||||
id="bonusModal"
|
||||
style="
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 50px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 800px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
background: white;
|
||||
padding: 30px;
|
||||
border: 2px solid #007bff;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
z-index: 1000;
|
||||
"
|
||||
>
|
||||
<h2>賞与計算</h2>
|
||||
<form id="bonusForm" onsubmit="calculateBonus(event)">
|
||||
<div class="form-group">
|
||||
<label>従業員 *</label>
|
||||
<select name="employee_id" id="bonusEmployeeSelect" required>
|
||||
<option value="">選択してください</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>対象年 *</label>
|
||||
<input
|
||||
type="text"
|
||||
name="bonus_year"
|
||||
inputmode="numeric"
|
||||
pattern="\d{4}"
|
||||
maxlength="4"
|
||||
oninput="sanitizeDigits(this,4)"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>対象月 *</label>
|
||||
<select name="bonus_month" required>
|
||||
<option value="">選択してください</option>
|
||||
<option value="1">1</option>
|
||||
<option value="2">2</option>
|
||||
<option value="3">3</option>
|
||||
<option value="4">4</option>
|
||||
<option value="5">5</option>
|
||||
<option value="6">6</option>
|
||||
<option value="7">7</option>
|
||||
<option value="8">8</option>
|
||||
<option value="9">9</option>
|
||||
<option value="10">10</option>
|
||||
<option value="11">11</option>
|
||||
<option value="12">12</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>賞与種別 *</label>
|
||||
<select name="bonus_type" required>
|
||||
<option value="">選択してください</option>
|
||||
<option value="夏季賞与">夏季賞与</option>
|
||||
<option value="冬季賞与">冬季賞与</option>
|
||||
<option value="決算賞与">決算賞与</option>
|
||||
<option value="その他">その他</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>支給日 *</label>
|
||||
<input
|
||||
type="date"
|
||||
name="payment_date"
|
||||
required
|
||||
min="1900-01-01"
|
||||
max="2099-12-31"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>基本賞与額</label>
|
||||
<input type="number" name="base_bonus" step="0.01" value="0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>業績賞与</label>
|
||||
<input
|
||||
type="number"
|
||||
name="performance_bonus"
|
||||
step="0.01"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>その他賞与</label>
|
||||
<input type="number" name="other_bonus" step="0.01" value="0" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>住民税</label>
|
||||
<input
|
||||
type="number"
|
||||
name="resident_tax"
|
||||
step="0.01"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>その他控除</label>
|
||||
<input
|
||||
type="number"
|
||||
name="other_deduction"
|
||||
step="0.01"
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">計算実行</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-secondary"
|
||||
onclick="closeBonusForm()"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 賞与明細表示 -->
|
||||
<div
|
||||
id="bonusDetail"
|
||||
class="payroll-detail"
|
||||
style="display: none"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API_BASE = "http://localhost:18080";
|
||||
let employeeMap = {};
|
||||
|
||||
// sanitize input to digits only and limit length
|
||||
function sanitizeDigits(el, maxLen) {
|
||||
if (!el) return;
|
||||
// remove non-digits
|
||||
el.value = el.value.replace(/\D/g, "");
|
||||
if (maxLen && el.value.length > maxLen)
|
||||
el.value = el.value.slice(0, maxLen);
|
||||
}
|
||||
|
||||
function validateYearMonth(yearStr, monthStr) {
|
||||
const now = new Date();
|
||||
const currentYear = now.getFullYear();
|
||||
if (!/^[0-9]{4}$/.test(String(yearStr)))
|
||||
return {
|
||||
ok: false,
|
||||
message: "対象年は4桁の数字で入力してください(例: 2026)",
|
||||
};
|
||||
const year = Number(yearStr);
|
||||
if (year < 1900 || year > currentYear + 1)
|
||||
return {
|
||||
ok: false,
|
||||
message: `対象年は1900〜${currentYear + 1}の範囲で入力してください`,
|
||||
};
|
||||
if (!/^[0-9]{1,2}$/.test(String(monthStr)))
|
||||
return {
|
||||
ok: false,
|
||||
message: "対象月は数字のみで入力してください(1〜12)",
|
||||
};
|
||||
const month = Number(monthStr);
|
||||
if (month < 1 || month > 12)
|
||||
return {
|
||||
ok: false,
|
||||
message: "対象月は1〜12の範囲で入力してください",
|
||||
};
|
||||
return { ok: true, year, month };
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
const response = await fetch(
|
||||
@@ -318,8 +578,14 @@
|
||||
);
|
||||
const employees = await response.json();
|
||||
|
||||
const select = document.getElementById("employeeSelect");
|
||||
select.innerHTML =
|
||||
// マップを作成して再利用できるようにする
|
||||
employeeMap = {};
|
||||
employees.forEach((emp) => {
|
||||
employeeMap[emp.employee_id] = emp;
|
||||
});
|
||||
|
||||
// 給与フォームと賞与フォーム両方のセレクトボックスを更新
|
||||
const optionsHtml =
|
||||
'<option value="">選択してください</option>' +
|
||||
employees
|
||||
.map(
|
||||
@@ -327,26 +593,51 @@
|
||||
`<option value="${emp.employee_id}">${emp.employee_code} - ${emp.name}</option>`
|
||||
)
|
||||
.join("");
|
||||
|
||||
const salarySelect = document.getElementById("employeeSelect");
|
||||
if (salarySelect) salarySelect.innerHTML = optionsHtml;
|
||||
|
||||
const bonusSelect = document.getElementById("bonusEmployeeSelect");
|
||||
if (bonusSelect) bonusSelect.innerHTML = optionsHtml;
|
||||
|
||||
return employees;
|
||||
}
|
||||
|
||||
async function loadPayrolls() {
|
||||
const year = document.getElementById("filterYear").value;
|
||||
const month = document.getElementById("filterMonth").value;
|
||||
const ym = document.getElementById("filterYearMonth").value;
|
||||
let year = null;
|
||||
let month = null;
|
||||
if (ym) {
|
||||
const parts = ym.split("-");
|
||||
if (parts.length === 2) {
|
||||
year = Number(parts[0]);
|
||||
month = Number(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.append("payroll_year", year);
|
||||
if (month) params.append("payroll_month", month);
|
||||
// employeeMap が空なら読み込む
|
||||
if (!employeeMap || Object.keys(employeeMap).length === 0) {
|
||||
await loadEmployees();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/payroll/calculation/?${params}`
|
||||
);
|
||||
const payrolls = await response.json();
|
||||
|
||||
const html = payrolls
|
||||
.map(
|
||||
(p) => `
|
||||
<div class="payroll-item" onclick="viewPayroll(${
|
||||
.map((p) => {
|
||||
const emp = employeeMap[p.employee_id];
|
||||
const empLabel = emp
|
||||
? `従業員ID: ${p.employee_id} | ${emp.employee_code} - ${emp.name}`
|
||||
: `従業員ID: ${p.employee_id}`;
|
||||
|
||||
return `
|
||||
<div class="payroll-item" style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="flex: 1; cursor: pointer;" onclick="viewPayroll(${
|
||||
p.payroll_id
|
||||
})">
|
||||
<div>
|
||||
@@ -358,9 +649,7 @@
|
||||
}">${getStatusLabel(p.status)}</span>
|
||||
</div>
|
||||
<div>
|
||||
従業員ID: ${p.employee_id} | 支給日: ${
|
||||
p.payment_date
|
||||
}
|
||||
${empLabel} | 支給日: ${p.payment_date}
|
||||
</div>
|
||||
<div>
|
||||
<strong>差引支給額: ¥${Number(
|
||||
@@ -373,8 +662,14 @@
|
||||
).toLocaleString()})
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
<button class="btn btn-danger btn-small" onclick="deletePayroll(${
|
||||
p.payroll_id
|
||||
}); event.stopPropagation();" style="margin-left: 10px; white-space: nowrap; background-color: #dc3545; color: #fff; border: none;">
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
document.getElementById("payrollList").innerHTML =
|
||||
@@ -416,11 +711,23 @@
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
const data = {};
|
||||
// validate year/month
|
||||
const yearStr = formData.get("payroll_year");
|
||||
const monthStr = formData.get("payroll_month");
|
||||
const valid = validateYearMonth(yearStr, monthStr);
|
||||
if (!valid.ok) {
|
||||
alert(valid.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {};
|
||||
formData.forEach((value, key) => {
|
||||
// convert numeric-like fields to Number, keep others as-is
|
||||
data[key] = isNaN(value) || value === "" ? value : Number(value);
|
||||
});
|
||||
// ensure payroll_year/month are numbers
|
||||
data.payroll_year = valid.year;
|
||||
data.payroll_month = valid.month;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
@@ -439,7 +746,11 @@
|
||||
viewPayroll(result.payroll_id);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert("計算に失敗しました: " + error.detail);
|
||||
const errorMessage =
|
||||
typeof error.detail === "string"
|
||||
? error.detail
|
||||
: JSON.stringify(error.detail || error);
|
||||
alert("計算に失敗しました:\n\n" + errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("計算に失敗しました");
|
||||
@@ -460,7 +771,7 @@
|
||||
);
|
||||
const employee = await empResponse.json();
|
||||
|
||||
// 源泉税控除対象人数を計算(19歳以上のみ)
|
||||
// 源泉税控除対象人数を計算(16歳以上19歳未満の一般扶養親族 + 19歳以上23歳未満の特定扶養親族)
|
||||
const dependentCount = employee.dependents
|
||||
? employee.dependents.filter((d) => {
|
||||
// 有効期間チェック
|
||||
@@ -481,8 +792,8 @@
|
||||
age--;
|
||||
}
|
||||
|
||||
// 19歳以上のみ源泉税控除対象(16-18歳は住民税のみ)
|
||||
return age >= 19;
|
||||
// 16歳以上が源泉税控除対象(16-18歳は一般扶養、19-22歳は特定扶養)
|
||||
return age >= 16;
|
||||
}).length
|
||||
: 0;
|
||||
|
||||
@@ -544,10 +855,6 @@
|
||||
<div class="detail-row total-row"><span>総支給額:</span><span>¥${Number(
|
||||
payroll.total_payment
|
||||
).toLocaleString()}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>控除項目</h3>
|
||||
<div class="detail-row"><span>健康保険:</span><span>¥${Number(
|
||||
payroll.health_insurance
|
||||
).toLocaleString()}</span></div>
|
||||
@@ -560,6 +867,12 @@
|
||||
<div class="detail-row"><span>雇用保険:</span><span>¥${Number(
|
||||
payroll.employment_insurance
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${Number(
|
||||
Number(payroll.health_insurance || 0) +
|
||||
Number(payroll.care_insurance || 0) +
|
||||
Number(payroll.pension_insurance || 0) +
|
||||
Number(payroll.employment_insurance || 0)
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row">
|
||||
<span>所得税 <small style="color:#666;">(甲欄${dependentCount}人)</small>:</span>
|
||||
<span>¥${Number(
|
||||
@@ -569,7 +882,7 @@
|
||||
<div style="padding: 5px 10px; background: #f0f8ff; border-radius: 3px; margin: 5px 0; font-size: 12px; color: #666;">
|
||||
<small>
|
||||
※課税対象額から源泉徴収税額表(甲欄)を参照して計算<br>
|
||||
扶養親族${dependentCount}人(19歳以上のみ対象)
|
||||
扶養親族${dependentCount}人(16歳以上が対象)
|
||||
</small>
|
||||
</div>
|
||||
<div class="detail-row"><span>住民税:</span><span>¥${Number(
|
||||
@@ -778,11 +1091,359 @@
|
||||
}
|
||||
}
|
||||
|
||||
// 初期化
|
||||
const now = new Date();
|
||||
document.getElementById("filterYear").value = now.getFullYear();
|
||||
document.getElementById("filterMonth").value = now.getMonth() + 1;
|
||||
async function deletePayroll(payrollId) {
|
||||
if (
|
||||
!confirm("この給与データを削除しますか?\nこの操作は取り消せません。")
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/payroll/calculation/${payrollId}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
alert("給与データを削除しました");
|
||||
document.getElementById("payrollDetail").style.display = "none";
|
||||
loadPayrolls();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(
|
||||
"削除に失敗しました: " + JSON.stringify(error.detail || error)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("削除に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
// 初期化: 月ピッカーにセットして一覧を読み込む
|
||||
const now = new Date();
|
||||
const ym =
|
||||
now.getFullYear() + "-" + String(now.getMonth() + 1).padStart(2, "0");
|
||||
document.getElementById("filterYearMonth").value = ym;
|
||||
document.getElementById("filterBonusYearMonth").value = ym;
|
||||
loadPayrolls();
|
||||
|
||||
// =====================================================
|
||||
// タブ切り替え機能
|
||||
// =====================================================
|
||||
let currentTab = "salary";
|
||||
|
||||
function switchTab(tab) {
|
||||
currentTab = tab;
|
||||
|
||||
// タブボタンの表示切り替え
|
||||
document
|
||||
.querySelectorAll(".nav-tab-calc")
|
||||
.forEach((btn) => btn.classList.remove("active"));
|
||||
event.target.classList.add("active");
|
||||
|
||||
// タブコンテンツの表示切り替え
|
||||
document.getElementById("tab-salary").classList.remove("active");
|
||||
document.getElementById("tab-bonus").classList.remove("active");
|
||||
document.getElementById("tab-" + tab).classList.add("active");
|
||||
|
||||
// 初回表示時に一覧を読み込む
|
||||
if (tab === "bonus") {
|
||||
loadBonus();
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 賞与計算関連
|
||||
// =====================================================
|
||||
async function loadBonus() {
|
||||
const ym = document.getElementById("filterBonusYearMonth").value;
|
||||
let year = null;
|
||||
let month = null;
|
||||
if (ym) {
|
||||
const parts = ym.split("-");
|
||||
if (parts.length === 2) {
|
||||
year = Number(parts[0]);
|
||||
month = Number(parts[1]);
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (year) params.append("bonus_year", year);
|
||||
if (month) params.append("bonus_month", month);
|
||||
|
||||
// employeeMapが空なら読み込む
|
||||
if (!employeeMap || Object.keys(employeeMap).length === 0) {
|
||||
await loadEmployees();
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/payroll/bonus/?${params}`);
|
||||
const bonuses = await response.json();
|
||||
|
||||
const html = bonuses
|
||||
.map((b) => {
|
||||
const emp = employeeMap[b.employee_id];
|
||||
const empLabel = emp
|
||||
? `${emp.employee_code} - ${emp.name}`
|
||||
: `従業員: 未登録`;
|
||||
|
||||
return `
|
||||
<div class="payroll-item" style="display: flex; justify-content: space-between; align-items: flex-start;">
|
||||
<div style="flex: 1; cursor: pointer;" onclick="viewBonus(${
|
||||
b.bonus_id
|
||||
})">
|
||||
<div>
|
||||
<strong>${b.bonus_year}年${
|
||||
b.bonus_month
|
||||
}月</strong>
|
||||
<span class="status-badge status-calculated">${
|
||||
b.bonus_type
|
||||
}</span>
|
||||
</div>
|
||||
<div>
|
||||
${empLabel} | 支給日: ${b.payment_date}
|
||||
</div>
|
||||
<div>
|
||||
<strong>差引支給額: ¥${Number(
|
||||
b.net_bonus
|
||||
).toLocaleString()}</strong>
|
||||
(総支給: ¥${Number(
|
||||
b.total_bonus
|
||||
).toLocaleString()} - 控除: ¥${Number(
|
||||
b.total_deduction || 0
|
||||
).toLocaleString()})
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteBonus(${
|
||||
b.bonus_id
|
||||
}); event.stopPropagation();" style="margin-left: 10px; white-space: nowrap; background-color: #dc3545; color: #fff; border: none;">
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
document.getElementById("bonusList").innerHTML =
|
||||
html || "<p>賞与データがありません</p>";
|
||||
} catch (error) {
|
||||
alert("賞与一覧の取得に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
function showBonusForm() {
|
||||
document.getElementById("bonusModal").style.display = "block";
|
||||
loadEmployees();
|
||||
|
||||
// デフォルト値設定
|
||||
const now = new Date();
|
||||
document.querySelector("#bonusForm [name='bonus_year']").value =
|
||||
now.getFullYear();
|
||||
document.querySelector("#bonusForm [name='bonus_month']").value =
|
||||
now.getMonth() + 1;
|
||||
}
|
||||
|
||||
function closeBonusForm() {
|
||||
document.getElementById("bonusModal").style.display = "none";
|
||||
document.getElementById("bonusForm").reset();
|
||||
}
|
||||
|
||||
async function calculateBonus(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
// validate year/month
|
||||
const yearStr = formData.get("bonus_year");
|
||||
const monthStr = formData.get("bonus_month");
|
||||
const valid = validateYearMonth(yearStr, monthStr);
|
||||
if (!valid.ok) {
|
||||
alert(valid.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {};
|
||||
formData.forEach((value, key) => {
|
||||
data[key] = isNaN(value) || value === "" ? value : Number(value);
|
||||
});
|
||||
data.bonus_year = valid.year;
|
||||
data.bonus_month = valid.month;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/payroll/bonus/calculate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const result = await response.json();
|
||||
alert("賞与計算が完了しました");
|
||||
closeBonusForm();
|
||||
viewBonus(result.bonus_id);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
const errorMessage =
|
||||
typeof error.detail === "string"
|
||||
? error.detail
|
||||
: JSON.stringify(error.detail || error);
|
||||
alert("計算に失敗しました:\n\n" + errorMessage);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("計算に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function viewBonus(bonusId) {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/payroll/bonus/${bonusId}`);
|
||||
const bonus = await response.json();
|
||||
|
||||
// 従業員情報を取得
|
||||
const empResponse = await fetch(
|
||||
`${API_BASE}/payroll/employees/${bonus.employee_id}`
|
||||
);
|
||||
const employee = await empResponse.json();
|
||||
|
||||
const html = `
|
||||
<h2>${bonus.bonus_year}年${
|
||||
bonus.bonus_month
|
||||
}月 賞与明細</h2>
|
||||
<p><strong>従業員:</strong> ${employee.employee_code} - ${
|
||||
employee.name
|
||||
} | <strong>支給日:</strong> ${
|
||||
bonus.payment_date
|
||||
} | <strong>賞与種別:</strong> ${bonus.bonus_type}</p>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>支給項目</h3>
|
||||
<div class="detail-row"><span>基本賞与額:</span><span>¥${Number(
|
||||
bonus.base_bonus || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>業績賞与:</span><span>¥${Number(
|
||||
bonus.performance_bonus || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>その他賞与:</span><span>¥${Number(
|
||||
bonus.other_bonus || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row total-row"><span>総支給額:</span><span>¥${Number(
|
||||
bonus.total_bonus || 0
|
||||
).toLocaleString()}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section">
|
||||
<h3>控除項目</h3>
|
||||
<div class="detail-row"><span>健康保険:</span><span>¥${Number(
|
||||
bonus.health_insurance || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>介護保険:</span><span>¥${Number(
|
||||
bonus.care_insurance || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>厚生年金:</span><span>¥${Number(
|
||||
bonus.pension_insurance || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>雇用保険:</span><span>¥${Number(
|
||||
bonus.employment_insurance || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span style="font-weight:bold">社会保険小計:</span><span style="font-weight:bold">¥${Number(
|
||||
Number(bonus.health_insurance || 0) +
|
||||
Number(bonus.care_insurance || 0) +
|
||||
Number(bonus.pension_insurance || 0) +
|
||||
Number(bonus.employment_insurance || 0)
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>所得税:</span><span>¥${Number(
|
||||
bonus.income_tax || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>住民税:</span><span>¥${Number(
|
||||
bonus.resident_tax || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row"><span>その他控除:</span><span>¥${Number(
|
||||
bonus.other_deduction || 0
|
||||
).toLocaleString()}</span></div>
|
||||
<div class="detail-row total-row"><span>総控除額:</span><span>¥${Number(
|
||||
bonus.total_deduction || 0
|
||||
).toLocaleString()}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="detail-section" style="background:#e7f3ff;">
|
||||
<div class="detail-row total-row" style="font-size:1.5em;">
|
||||
<span>差引支給額:</span>
|
||||
<span>¥${Number(
|
||||
bonus.net_bonus || 0
|
||||
).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" onclick="recalculateBonus(${
|
||||
bonus.bonus_id
|
||||
})">再計算</button>
|
||||
<button class="btn btn-secondary" onclick="document.getElementById('bonusDetail').style.display='none'">閉じる</button>
|
||||
`;
|
||||
|
||||
document.getElementById("bonusDetail").innerHTML = html;
|
||||
document.getElementById("bonusDetail").style.display = "block";
|
||||
document
|
||||
.getElementById("bonusDetail")
|
||||
.scrollIntoView({ behavior: "smooth" });
|
||||
} catch (error) {
|
||||
alert("賞与明細の取得に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteBonus(bonusId) {
|
||||
if (
|
||||
!confirm("この賞与データを削除しますか?\nこの操作は取り消せません。")
|
||||
)
|
||||
return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE}/payroll/bonus/${bonusId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert("賞与データを削除しました");
|
||||
document.getElementById("bonusDetail").style.display = "none";
|
||||
loadBonus();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(
|
||||
"削除に失敗しました: " + JSON.stringify(error.detail || error)
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("削除に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function recalculateBonus(bonusId) {
|
||||
if (!confirm("賞与を再計算しますか?")) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/payroll/bonus/${bonusId}/recalculate`,
|
||||
{
|
||||
method: "POST",
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
alert("再計算が完了しました");
|
||||
viewBonus(bonusId);
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert("再計算に失敗しました: " + error.detail);
|
||||
}
|
||||
} catch (error) {
|
||||
alert("再計算に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -916,7 +916,7 @@
|
||||
employee.employee_id
|
||||
})" style="padding: 5px 10px; font-size: 12px;">扶養家族追加</button></h3>
|
||||
${(() => {
|
||||
// 源泉税控除対象人数を計算(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 @@
|
||||
<div style="background: #f0f8ff; padding: 10px; border-radius: 5px; margin-bottom: 15px;">
|
||||
<strong style="color: #0066cc;">甲欄扶養親族人数(源泉税控除対象): ${koureiCount}人</strong>
|
||||
<small style="display: block; margin-top: 5px; color: #666;">
|
||||
※19歳以上の扶養親族が対象(16-18歳は住民税のみ対象のため含まれません)
|
||||
※16歳以上の扶養親族が対象(一般扶養親族16-18歳、特定扶養親族19-22歳、老人扶養親族70歳以上)
|
||||
</small>
|
||||
</div>
|
||||
`;
|
||||
@@ -962,7 +963,7 @@
|
||||
: ""
|
||||
}
|
||||
${
|
||||
taxStatus?.isDeductible
|
||||
taxStatus?.label
|
||||
? ` <span style="color:${taxStatus.color}; font-weight:bold;">[${taxStatus.label}]</span>`
|
||||
: ""
|
||||
}
|
||||
@@ -1004,7 +1005,8 @@
|
||||
);
|
||||
return (
|
||||
taxStatus?.isDeductible &&
|
||||
(taxStatus.type === "控除対象扶養親族" ||
|
||||
(taxStatus.type === "一般扶養親族" ||
|
||||
taxStatus.type === "控除対象扶養親族" ||
|
||||
taxStatus.type === "老人扶養親族")
|
||||
);
|
||||
}).length;
|
||||
@@ -1015,8 +1017,8 @@
|
||||
<strong>📋 源泉税控除の説明:</strong>
|
||||
<ul style="margin: 10px 0 0 20px; font-size: 13px; line-height: 1.6;">
|
||||
<li><strong style="color: purple;">老人扶養親族(70歳以上)</strong>: 源泉税・住民税の両方で控除対象</li>
|
||||
<li><strong style="color: green;">控除対象扶養親族(19歳以上)</strong>: 源泉税・住民税の両方で控除対象</li>
|
||||
<li><strong style="color: orange;">一般扶養親族(16-18歳)</strong>: 住民税のみ控除対象(源泉税では控除されません)</li>
|
||||
<li><strong style="color: green;">控除対象扶養親族(19歳以上23歳未満)</strong>: 源泉税・住民税の両方で控除対象</li>
|
||||
<li><strong style="color: orange;">一般扶養親族(16歳以上19歳未満)</strong>: 源泉税・住民税の両方で控除対象</li>
|
||||
<li><strong style="color: #999;">16歳未満</strong>: 控除対象外</li>
|
||||
</ul>
|
||||
<p style="margin: 10px 0 0 0; font-size: 12px; color: #666;">
|
||||
@@ -1253,6 +1255,11 @@
|
||||
<label>生年月日</label>
|
||||
<input type="date" name="birth_date" min="1900-01-01" max="2099-12-31" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>マイナンバーカード番号</label>
|
||||
<input type="text" name="mynumber" placeholder="例:123456789012" maxlength="12" />
|
||||
<small>12桁の数字(任意入力)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_spouse" value="true" />
|
||||
@@ -1304,6 +1311,7 @@
|
||||
name: formData.get("name"),
|
||||
relationship: formData.get("relationship"),
|
||||
birth_date: formData.get("birth_date") || null,
|
||||
mynumber: formData.get("mynumber") || null,
|
||||
is_spouse: formData.get("is_spouse") === "true",
|
||||
is_disabled: formData.get("is_disabled") === "true",
|
||||
income_amount: parseFloat(formData.get("income_amount")) || 0,
|
||||
@@ -1335,19 +1343,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// その年の12月31日時点の年齢を計算
|
||||
function calculateAgeAtYearEnd(
|
||||
birthDate,
|
||||
targetYear = new Date().getFullYear()
|
||||
) {
|
||||
// 現在時点の満年齢を計算
|
||||
function calculateAgeAtYearEnd(birthDate) {
|
||||
if (!birthDate) return null;
|
||||
const today = new Date();
|
||||
const birth = new Date(birthDate);
|
||||
const yearEnd = new Date(targetYear, 11, 31); // 12月31日
|
||||
let age = yearEnd.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = yearEnd.getMonth() - birth.getMonth();
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const monthDiff = today.getMonth() - birth.getMonth();
|
||||
if (
|
||||
monthDiff < 0 ||
|
||||
(monthDiff === 0 && yearEnd.getDate() < birth.getDate())
|
||||
(monthDiff === 0 && today.getDate() < birth.getDate())
|
||||
) {
|
||||
age--;
|
||||
}
|
||||
@@ -1379,7 +1384,7 @@
|
||||
return {
|
||||
isDeductible: true,
|
||||
type: "一般扶養親族",
|
||||
label: "一般扶養親族(住民税のみ)",
|
||||
label: "一般扶養親族(源泉税・住民税)",
|
||||
color: "orange",
|
||||
age,
|
||||
};
|
||||
@@ -1387,7 +1392,7 @@
|
||||
return {
|
||||
isDeductible: false,
|
||||
type: null,
|
||||
label: null,
|
||||
label: "源泉税控除対象外",
|
||||
color: null,
|
||||
age,
|
||||
};
|
||||
@@ -1458,6 +1463,13 @@
|
||||
dependent.birth_date || ""
|
||||
}" min="1900-01-01" max="2099-12-31" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>マイナンバーカード番号</label>
|
||||
<input type="text" name="mynumber" value="${
|
||||
dependent.mynumber || ""
|
||||
}" placeholder="例:123456789012" maxlength="12" />
|
||||
<small>12桁の数字(任意入力)</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="is_spouse" value="true" ${
|
||||
@@ -1515,6 +1527,7 @@
|
||||
name: formData.get("name"),
|
||||
relationship: formData.get("relationship"),
|
||||
birth_date: formData.get("birth_date") || null,
|
||||
mynumber: formData.get("mynumber") || null,
|
||||
is_spouse: formData.get("is_spouse") === "true",
|
||||
is_disabled: formData.get("is_disabled") === "true",
|
||||
income_amount: parseFloat(formData.get("income_amount")) || 0,
|
||||
|
||||
@@ -159,6 +159,19 @@
|
||||
value="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label style="display: flex; align-items: center">
|
||||
<input
|
||||
type="checkbox"
|
||||
name="employment_insurance_eligible"
|
||||
id="employment_insurance_eligible"
|
||||
checked
|
||||
style="width: auto; margin-right: 8px"
|
||||
/>
|
||||
<span>雇用保険加入対象</span>
|
||||
</label>
|
||||
<small>チェックを入れると雇用保険に加入します</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>適用開始日 *</label>
|
||||
<input
|
||||
@@ -267,6 +280,7 @@
|
||||
<th>支給形態</th>
|
||||
<th>通勤手当</th>
|
||||
<th>その他手当</th>
|
||||
<th>雇用保険</th>
|
||||
<th>適用期間</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
@@ -290,6 +304,11 @@
|
||||
<td>${s.payment_type}</td>
|
||||
<td>¥${Number(s.commute_allowance).toLocaleString()}</td>
|
||||
<td>¥${Number(s.other_allowance).toLocaleString()}</td>
|
||||
<td>${
|
||||
s.employment_insurance_eligible !== false
|
||||
? "✓ 対象"
|
||||
: "✗ 非対象"
|
||||
}</td>
|
||||
<td>${s.valid_from} ~ ${s.valid_to || "現在"}</td>
|
||||
<td>
|
||||
<button class="btn btn-primary" onclick="editSetting(${
|
||||
@@ -300,7 +319,7 @@
|
||||
`
|
||||
)
|
||||
.join("")
|
||||
: '<tr><td colspan="10" style="text-align:center;">給与設定が登録されていません</td></tr>'
|
||||
: '<tr><td colspan="11" style="text-align:center;">給与設定が登録されていません</td></tr>'
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -339,6 +358,8 @@
|
||||
setting.commute_allowance;
|
||||
document.getElementById("other_allowance").value =
|
||||
setting.other_allowance;
|
||||
document.getElementById("employment_insurance_eligible").checked =
|
||||
setting.employment_insurance_eligible !== false;
|
||||
document.getElementById("valid_from").value = setting.valid_from;
|
||||
document.getElementById("valid_to").value =
|
||||
setting.valid_to || "";
|
||||
@@ -386,6 +407,9 @@
|
||||
payment_type: formData.get("payment_type"),
|
||||
commute_allowance: parseFloat(formData.get("commute_allowance")) || 0,
|
||||
other_allowance: parseFloat(formData.get("other_allowance")) || 0,
|
||||
employment_insurance_eligible: document.getElementById(
|
||||
"employment_insurance_eligible"
|
||||
).checked,
|
||||
valid_from: formData.get("valid_from"),
|
||||
};
|
||||
|
||||
|
||||
@@ -547,7 +547,7 @@
|
||||
|
||||
<!-- 所得税率表タブ -->
|
||||
<div id="tab-tax" class="tab-content">
|
||||
<h2>所得税率表</h2>
|
||||
<h2>所得税率表(年度1月~12月)</h2>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
onclick="document.getElementById('csvFile').click()"
|
||||
@@ -567,13 +567,13 @@
|
||||
|
||||
<!-- 標準報酬月額表タブ -->
|
||||
<div id="tab-standard-remuneration" class="tab-content">
|
||||
<h2>標準報酬月額表</h2>
|
||||
<h2>標準報酬月額表(年度3月~翌年2月)</h2>
|
||||
<div class="info-box">
|
||||
<strong>標準報酬月額表について:</strong>
|
||||
<ul style="margin: 10px 0 0 20px">
|
||||
<li>健康保険・厚生年金保険の保険料額表をインポートします</li>
|
||||
<li>都道府県ごとの料率表に対応しています</li>
|
||||
<li>年度と都道府県を指定してください</li>
|
||||
<li>年度をクリックして展開してください</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -594,7 +594,6 @@
|
||||
</div>
|
||||
|
||||
<div id="stdRemunerationYearsList" style="margin-top: 20px"></div>
|
||||
<div id="stdRemunerationDataContainer" style="margin-top: 20px"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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 =
|
||||
"<p>データがありません。インポートしてください。</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const html = years
|
||||
.sort((a, b) => b - a)
|
||||
.map(
|
||||
(year) => `
|
||||
<div class="year-item" id="std-year-${year}">
|
||||
<div class="year-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="cursor: pointer; flex: 1;" onclick="toggleStdRemunerationYear(${year})">
|
||||
📅 ${year}年度 <span style="font-size: 14px; color: #666;">(クリックして展開)</span>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteStdRemunerationYear(${year})" style="margin-left: 10px;">
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
<div class="year-data" id="std-data-${year}">
|
||||
<div id="std-table-${year}">読み込み中...</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.getElementById("stdRemunerationYearsList").innerHTML = html;
|
||||
} catch (error) {
|
||||
console.error("年度リストの取得に失敗:", error);
|
||||
document.getElementById("stdRemunerationYearsList").innerHTML =
|
||||
'<p style="color:red;">年度リストの取得に失敗しました</p>';
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
"<p>該当するデータがありません</p>";
|
||||
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 += `<h3 style="margin-top: 20px; margin-bottom: 10px; color: #333;">${prefecture}</h3>`;
|
||||
tablesHtml += renderStdRemunerationTable(prefData, metadata);
|
||||
}
|
||||
|
||||
document.getElementById(`std-table-${year}`).innerHTML = tablesHtml;
|
||||
} catch (error) {
|
||||
console.error("データ読み込みエラー:", error);
|
||||
document.getElementById(
|
||||
`std-table-${year}`
|
||||
).innerHTML = `<p style="color:red;">データの取得に失敗しました: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStdRemunerationTable(data, metadata = {}) {
|
||||
if (!data || data.length === 0) {
|
||||
return "<p>データがありません。</p>";
|
||||
}
|
||||
|
||||
// A1セルの内容を表示(あれば)
|
||||
let sourceHtml = "";
|
||||
console.log("renderStdRemunerationTable - metadata内容:", metadata);
|
||||
console.log(
|
||||
"renderStdRemunerationTable - metadata.source:",
|
||||
metadata.source
|
||||
);
|
||||
if (metadata.source) {
|
||||
sourceHtml = `<p style="margin-bottom: 15px; padding: 10px; background: #f0f8ff; border-left: 3px solid #0066cc; color: #333;"><strong>対象期間:</strong> ${metadata.source}</p>`;
|
||||
} 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 = `
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="3">等級</th>
|
||||
<th rowspan="3">標準報酬月額</th>
|
||||
<th rowspan="3">報酬月額 以上</th>
|
||||
<th rowspan="3">報酬月額 未満</th>
|
||||
<th colspan="2">健康保険料(介護保険なし)</th>
|
||||
<th colspan="2">健康保険料(介護保険あり)</th>
|
||||
<th colspan="2">厚生年金保険料</th>
|
||||
</tr>
|
||||
<tr style="background-color: #f0f0f0;">
|
||||
<th colspan="2" style="text-align: center; font-weight: bold; color: #007bff;">
|
||||
${
|
||||
rates.healthNoCareRate !== undefined
|
||||
? rates.healthNoCareRate.toFixed(2) + "%"
|
||||
: "-"
|
||||
}
|
||||
</th>
|
||||
<th colspan="2" style="text-align: center; font-weight: bold; color: #007bff;">
|
||||
${
|
||||
rates.healthWithCareRate !== undefined
|
||||
? rates.healthWithCareRate.toFixed(2) + "%"
|
||||
: "-"
|
||||
}
|
||||
</th>
|
||||
<th colspan="2" style="text-align: center; font-weight: bold; color: #007bff;">
|
||||
${
|
||||
rates.pensionRate !== undefined
|
||||
? rates.pensionRate.toFixed(2) + "%"
|
||||
: "-"
|
||||
}
|
||||
</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>全額</th>
|
||||
<th>折半額</th>
|
||||
<th>全額</th>
|
||||
<th>折半額</th>
|
||||
<th>全額</th>
|
||||
<th>折半額</th>
|
||||
</tr>
|
||||
</thead>
|
||||
`;
|
||||
const tbody = `
|
||||
<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 `
|
||||
<tr>
|
||||
<td>${row.grade ?? ""}</td>
|
||||
<td>${Number(row.monthly_amount ?? 0).toLocaleString()}</td>
|
||||
<td>${Number(row.salary_from ?? 0).toLocaleString()}</td>
|
||||
<td>${Number(row.salary_to ?? 0).toLocaleString()}</td>
|
||||
<td>${Number(
|
||||
row.health_insurance_no_care ?? 0
|
||||
).toLocaleString()}</td>
|
||||
<td>${Number(healthNoCareHalf ?? 0).toLocaleString()}</td>
|
||||
<td>${Number(
|
||||
row.health_insurance_with_care ?? 0
|
||||
).toLocaleString()}</td>
|
||||
<td>${Number(healthWithCareHalf ?? 0).toLocaleString()}</td>
|
||||
<td>${Number(
|
||||
row.pension_insurance ?? 0
|
||||
).toLocaleString()}</td>
|
||||
<td>${Number(pensionHalf ?? 0).toLocaleString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("")}
|
||||
</tbody>
|
||||
`;
|
||||
return (
|
||||
sourceHtml +
|
||||
metadataHtml +
|
||||
`<table style="width:100%; border-collapse: collapse; border: 1px solid #ccc;">${thead}${tbody}</table>`
|
||||
);
|
||||
}
|
||||
|
||||
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 =
|
||||
"<p>データが削除されました。Excelファイルを再度インポートしてください。</p>";
|
||||
} 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 @@
|
||||
}</td>
|
||||
<td>${rate.notes || "-"}</td>
|
||||
<td>
|
||||
<button class="btn btn-primary btn-small" onclick="copyInsuranceRate(${
|
||||
rate.rate_id
|
||||
})">複製</button>
|
||||
<button class="btn btn-primary btn-small" onclick="editInsuranceRate(${
|
||||
rate.rate_id
|
||||
})">編集</button>
|
||||
@@ -761,6 +1160,55 @@
|
||||
document.getElementById("editRateId").value = rateId;
|
||||
|
||||
// フォームにデータを入力
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
@@ -776,13 +1224,6 @@
|
||||
form.querySelector('[name="care_insurance_age_threshold"]').value =
|
||||
rate.care_insurance_age_threshold || "";
|
||||
form.querySelector('[name="notes"]').value = rate.notes || "";
|
||||
|
||||
// フォームを表示
|
||||
document.getElementById("insuranceForm").style.display = "block";
|
||||
} catch (error) {
|
||||
alert("データの取得に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveInsuranceRate(event) {
|
||||
@@ -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) => `
|
||||
<div class="year-item" id="year-${year}" onclick="toggleYear(${year})">
|
||||
<div class="year-header">
|
||||
<div class="year-item" id="year-${year}">
|
||||
<div class="year-header" style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div style="cursor: pointer; flex: 1;" onclick="toggleYear(${year})">
|
||||
📅 ${year}年度 <span style="font-size: 14px; color: #666;">(クリックして展開)</span>
|
||||
</div>
|
||||
<button class="btn btn-danger btn-small" onclick="deleteTaxDataYear(${year})" style="margin-left: 10px;">
|
||||
削除
|
||||
</button>
|
||||
</div>
|
||||
<div class="year-data" id="data-${year}">
|
||||
<div class="filter-controls">
|
||||
<label>扶養人数でフィルター: </label>
|
||||
<select id="filter-${year}" onchange="loadYearData(${year})">
|
||||
<select id="filter-${year}" onchange="loadYearData(${year})" onclick="event.stopPropagation()">
|
||||
<option value="">全て</option>
|
||||
<option value="0">0人</option>
|
||||
<option value="1">1人</option>
|
||||
@@ -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 =
|
||||
"<p>データがありません。Excelファイルをインポートしてください。</p>";
|
||||
return;
|
||||
}
|
||||
|
||||
const html = years
|
||||
.map(
|
||||
(year) => `
|
||||
<div class="year-item" id="std-year-${year}">
|
||||
<div class="year-header" onclick="toggleStdRemunerationYear(${year})">
|
||||
<span class="year-title">${year}年度</span>
|
||||
<span class="expand-icon">▼</span>
|
||||
</div>
|
||||
<div class="year-content" id="std-content-${year}"></div>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("");
|
||||
|
||||
document.getElementById("stdRemunerationYearsList").innerHTML = html;
|
||||
} catch (error) {
|
||||
alert("年度リストの取得に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleStdRemunerationYear(year) {
|
||||
const yearItem = document.getElementById(`std-year-${year}`);
|
||||
const wasExpanded = yearItem.classList.contains("expanded");
|
||||
|
||||
// 他の年度を閉じる
|
||||
document.querySelectorAll(".year-item").forEach((item) => {
|
||||
item.classList.remove("expanded");
|
||||
});
|
||||
|
||||
if (!wasExpanded) {
|
||||
yearItem.classList.add("expanded");
|
||||
await loadStdRemunerationYearData(year);
|
||||
} else {
|
||||
document.getElementById(`std-content-${year}`).innerHTML = "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStdRemunerationYearData(year) {
|
||||
const filterSelect = document.getElementById(`std-filter-${year}`);
|
||||
const prefectureFilter = filterSelect ? filterSelect.value : "";
|
||||
const params = new URLSearchParams({ year: year });
|
||||
if (prefectureFilter) {
|
||||
params.append("prefecture", prefectureFilter);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${API_BASE}/payroll/settings/standard-remuneration?${params}`
|
||||
);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.length === 0) {
|
||||
document.getElementById(`std-content-${year}`).innerHTML =
|
||||
'<p class="no-data">データがありません</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 都道府県でグループ化
|
||||
const groupedByPrefecture = {};
|
||||
data.forEach((row) => {
|
||||
if (!groupedByPrefecture[row.prefecture]) {
|
||||
groupedByPrefecture[row.prefecture] = [];
|
||||
}
|
||||
groupedByPrefecture[row.prefecture].push(row);
|
||||
});
|
||||
|
||||
// 都道府県一覧を取得
|
||||
const prefectures = Object.keys(groupedByPrefecture).sort();
|
||||
|
||||
const html = `
|
||||
<div class="filter-section">
|
||||
<label>都道府県で絞り込み:</label>
|
||||
<select id="std-filter-${year}" onchange="loadStdRemunerationYearData(${year})" style="margin-left: 10px">
|
||||
<option value="">すべて</option>
|
||||
${prefectures
|
||||
.map(
|
||||
(pref) =>
|
||||
`<option value="${pref}" ${
|
||||
prefectureFilter === pref ? "selected" : ""
|
||||
}>${pref}</option>`
|
||||
)
|
||||
.join("")}
|
||||
</select>
|
||||
</div>
|
||||
${Object.entries(groupedByPrefecture)
|
||||
.map(
|
||||
([prefecture, rows]) => `
|
||||
<div style="margin-top: 20px">
|
||||
<h4>${prefecture}</h4>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th rowspan="2">等級</th>
|
||||
<th rowspan="2">標準報酬月額</th>
|
||||
<th colspan="2">報酬月額</th>
|
||||
<th colspan="2">健康保険料</th>
|
||||
<th rowspan="2">厚生年金保険料</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>以上</th>
|
||||
<th>未満</th>
|
||||
<th>介護保険なし</th>
|
||||
<th>介護保険あり</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${rows
|
||||
.map(
|
||||
(row) => `
|
||||
<tr>
|
||||
<td>${row.grade}</td>
|
||||
<td class="number">${formatNumber(
|
||||
row.monthly_amount
|
||||
)}</td>
|
||||
<td class="number">${formatNumber(row.salary_from)}</td>
|
||||
<td class="number">${
|
||||
row.salary_to ? formatNumber(row.salary_to) : "〜"
|
||||
}</td>
|
||||
<td class="number">${formatNumber(
|
||||
row.health_insurance_no_care
|
||||
)}</td>
|
||||
<td class="number">${formatNumber(
|
||||
row.health_insurance_with_care
|
||||
)}</td>
|
||||
<td class="number">${formatNumber(
|
||||
row.pension_insurance
|
||||
)}</td>
|
||||
</tr>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`
|
||||
)
|
||||
.join("")}
|
||||
`;
|
||||
|
||||
document.getElementById(`std-content-${year}`).innerHTML = html;
|
||||
} catch (error) {
|
||||
alert("データの取得に失敗しました");
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function importStdRemunerationTable(event) {
|
||||
const file = event.target.files[0];
|
||||
if (!file) return;
|
||||
|
||||
// 都道府県をプロンプトで取得
|
||||
const prefecture = prompt(
|
||||
"都道府県名を入力してください(例: 東京):",
|
||||
"都道府県を入力してください(例: 東京、神奈川):",
|
||||
""
|
||||
);
|
||||
if (!prefecture || !prefecture.trim()) {
|
||||
alert("都道府県名を入力してください");
|
||||
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 @@
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
console.log("レスポンスステータス:", response.status);
|
||||
const result = await response.json();
|
||||
alert(
|
||||
`インポートしました: ${result.imported_count}件\n年度: ${
|
||||
result.year
|
||||
}年\n都道府県: ${prefecture}\n\n取得元: ${result.source || ""}`
|
||||
console.log("レスポンスボディ:", result);
|
||||
|
||||
if (response.ok) {
|
||||
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");
|
||||
}
|
||||
|
||||
// =====================================================
|
||||
// 初期化
|
||||
// =====================================================
|
||||
|
||||
Reference in New Issue
Block a user