feat: 給与所得の源泉徴収票機能を追加
- 新規モジュール: backend/app/payroll/withholding_slip/ - schemas.py: 源泉徴収票データモデル - service.py: 年間集計・給与所得控除・控除額計算ロジック - router.py: POST /payroll/withholding-slip/generate - frontend/withholding-slip.html: 票面プレビュー・印刷(3部)ページ - frontend/payroll.html: 源泉徴収票メニューカード追加 - backend/app/main.py: ルーター登録
This commit is contained in:
@@ -97,6 +97,9 @@ app.include_router(cash.router)
|
|||||||
from app.routers import file_upload
|
from app.routers import file_upload
|
||||||
app.include_router(file_upload.router)
|
app.include_router(file_upload.router)
|
||||||
|
|
||||||
|
from app.modules.egov.router import router as egov_router
|
||||||
|
app.include_router(egov_router)
|
||||||
|
|
||||||
# ─────────────────────────
|
# ─────────────────────────
|
||||||
# 給与モジュール (Payroll Module)
|
# 給与モジュール (Payroll Module)
|
||||||
# ─────────────────────────
|
# ─────────────────────────
|
||||||
@@ -118,6 +121,9 @@ app.include_router(payroll_bonus_router)
|
|||||||
from app.payroll.vouchers.router import router as payroll_vouchers_router
|
from app.payroll.vouchers.router import router as payroll_vouchers_router
|
||||||
app.include_router(payroll_vouchers_router)
|
app.include_router(payroll_vouchers_router)
|
||||||
|
|
||||||
|
from app.payroll.withholding_slip.router import router as withholding_slip_router
|
||||||
|
app.include_router(withholding_slip_router)
|
||||||
|
|
||||||
|
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
import os
|
import os
|
||||||
|
|||||||
0
backend/app/payroll/withholding_slip/__init__.py
Normal file
0
backend/app/payroll/withholding_slip/__init__.py
Normal file
71
backend/app/payroll/withholding_slip/router.py
Normal file
71
backend/app/payroll/withholding_slip/router.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
"""
|
||||||
|
給与所得の源泉徴収票 APIルーター
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, HTTPException, Query
|
||||||
|
from typing import List, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .schemas import WithholdingSlipRequest, WithholdingSlipResponse
|
||||||
|
from .service import get_withholding_slips
|
||||||
|
from app.core.database import get_connection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/payroll/withholding-slip", tags=["Payroll - Withholding Slip"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/generate", response_model=WithholdingSlipResponse)
|
||||||
|
def generate_withholding_slips(request: WithholdingSlipRequest):
|
||||||
|
"""
|
||||||
|
指定年度・従業員の給与所得の源泉徴収票データを生成する
|
||||||
|
"""
|
||||||
|
if not request.employee_ids:
|
||||||
|
raise HTTPException(status_code=400, detail="従業員IDを指定してください")
|
||||||
|
if request.tax_year < 2000 or request.tax_year > 2099:
|
||||||
|
raise HTTPException(status_code=400, detail="年度が不正です")
|
||||||
|
|
||||||
|
try:
|
||||||
|
slips = get_withholding_slips(request.tax_year, request.employee_ids)
|
||||||
|
return WithholdingSlipResponse(
|
||||||
|
tax_year=request.tax_year,
|
||||||
|
slips=slips,
|
||||||
|
total_count=len(slips)
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"源泉徴収票生成エラー: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/employees")
|
||||||
|
def get_employees_for_slip():
|
||||||
|
"""アクティブな従業員リストを取得(源泉徴収票用)"""
|
||||||
|
try:
|
||||||
|
with get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT employee_id, employee_code, name, name_kana
|
||||||
|
FROM employees
|
||||||
|
WHERE is_active = true
|
||||||
|
ORDER BY employee_code
|
||||||
|
""")
|
||||||
|
rows = cur.fetchall()
|
||||||
|
result = []
|
||||||
|
for row in rows:
|
||||||
|
if isinstance(row, dict):
|
||||||
|
result.append({
|
||||||
|
"id": row["employee_id"],
|
||||||
|
"code": row["employee_code"],
|
||||||
|
"name": row["name"],
|
||||||
|
"name_kana": row.get("name_kana", "")
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
result.append({
|
||||||
|
"id": row[0],
|
||||||
|
"code": row[1],
|
||||||
|
"name": row[2],
|
||||||
|
"name_kana": row[3] if len(row) > 3 else ""
|
||||||
|
})
|
||||||
|
return {"employees": result, "total": len(result)}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"従業員リスト取得エラー: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
95
backend/app/payroll/withholding_slip/schemas.py
Normal file
95
backend/app/payroll/withholding_slip/schemas.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""
|
||||||
|
給与所得の源泉徴収票 スキーマ定義
|
||||||
|
"""
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import Optional, List
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class DependentInfo(BaseModel):
|
||||||
|
"""扶養親族情報"""
|
||||||
|
name: str
|
||||||
|
name_kana: Optional[str] = None
|
||||||
|
relationship: Optional[str] = None
|
||||||
|
birth_date: Optional[str] = None
|
||||||
|
is_spouse: bool = False
|
||||||
|
is_disabled: bool = False
|
||||||
|
income_amount: Decimal = Decimal("0")
|
||||||
|
my_number: Optional[str] = None
|
||||||
|
deduction_type: Optional[str] = None # 一般/特定/老人(同居)/老人(その他)
|
||||||
|
deduction_amount: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class WithholdingSlipData(BaseModel):
|
||||||
|
"""源泉徴収票データ"""
|
||||||
|
# 従業員情報
|
||||||
|
employee_id: int
|
||||||
|
employee_code: str
|
||||||
|
employee_name: str
|
||||||
|
employee_name_kana: Optional[str] = None
|
||||||
|
employee_address: Optional[str] = None
|
||||||
|
employee_birth_date: Optional[str] = None
|
||||||
|
my_number: Optional[str] = None
|
||||||
|
|
||||||
|
# 対象年
|
||||||
|
tax_year: int
|
||||||
|
|
||||||
|
# 支払金額
|
||||||
|
total_payment: Decimal = Decimal("0") # 年間総支給額(通勤手当含む)
|
||||||
|
total_payment_excl_commute: Decimal = Decimal("0") # 通勤手当を除く支払金額(源泉徴収票記載用)
|
||||||
|
|
||||||
|
# 給与所得控除後の金額
|
||||||
|
kyuyo_shotoku: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
# 社会保険料等の金額
|
||||||
|
social_insurance_total: Decimal = Decimal("0")
|
||||||
|
health_insurance_total: Decimal = Decimal("0")
|
||||||
|
care_insurance_total: Decimal = Decimal("0")
|
||||||
|
pension_total: Decimal = Decimal("0")
|
||||||
|
employment_insurance_total: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
# 基礎控除
|
||||||
|
basic_deduction: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
# 配偶者控除
|
||||||
|
has_spouse_deduction: bool = False
|
||||||
|
spouse_deduction_amount: Decimal = Decimal("0")
|
||||||
|
spouse_name: Optional[str] = None
|
||||||
|
spouse_name_kana: Optional[str] = None
|
||||||
|
spouse_income: Decimal = Decimal("0")
|
||||||
|
spouse_deduction_type: Optional[str] = None # 配偶者控除/配偶者特別控除
|
||||||
|
|
||||||
|
# 扶養親族
|
||||||
|
dependents_over16: List[DependentInfo] = [] # 16歳以上控除対象扶養親族
|
||||||
|
dependents_under16: List[DependentInfo] = [] # 16歳未満扶養親族
|
||||||
|
dependent_deduction_total: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
# 所得控除の額の合計額
|
||||||
|
total_deduction_amount: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
# 源泉徴収税額
|
||||||
|
income_tax_withheld: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
# 月数内訳
|
||||||
|
months_with_salary: int = 0
|
||||||
|
months_with_bonus: int = 0
|
||||||
|
|
||||||
|
# 摘要
|
||||||
|
remarks: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WithholdingSlipRequest(BaseModel):
|
||||||
|
"""源泉徴収票生成リクエスト"""
|
||||||
|
tax_year: int
|
||||||
|
employee_ids: List[int]
|
||||||
|
company_name: Optional[str] = None
|
||||||
|
company_address: Optional[str] = None
|
||||||
|
company_phone: Optional[str] = None
|
||||||
|
company_my_number: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class WithholdingSlipResponse(BaseModel):
|
||||||
|
"""源泉徴収票レスポンス"""
|
||||||
|
tax_year: int
|
||||||
|
slips: List[WithholdingSlipData]
|
||||||
|
total_count: int
|
||||||
388
backend/app/payroll/withholding_slip/service.py
Normal file
388
backend/app/payroll/withholding_slip/service.py
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
"""
|
||||||
|
給与所得の源泉徴収票 計算サービス
|
||||||
|
"""
|
||||||
|
from decimal import Decimal, ROUND_DOWN
|
||||||
|
from datetime import date
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from app.core.database import get_connection
|
||||||
|
from .schemas import WithholdingSlipData, DependentInfo
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# 通勤手当の非課税限度額(月額150,000円)
|
||||||
|
COMMUTE_TAX_FREE_MONTHLY = Decimal("150000")
|
||||||
|
|
||||||
|
|
||||||
|
def calc_kyuyo_shotoku(annual_income: Decimal) -> Decimal:
|
||||||
|
"""
|
||||||
|
給与所得控除後の金額を計算する(年末調整用)
|
||||||
|
給与所得控除額を差し引いた後、1円未満切り捨て
|
||||||
|
"""
|
||||||
|
income = int(annual_income)
|
||||||
|
if income <= 550_000:
|
||||||
|
# 給与所得は0
|
||||||
|
return Decimal("0")
|
||||||
|
elif income <= 1_625_000:
|
||||||
|
kojo = 550_000
|
||||||
|
elif income <= 1_800_000:
|
||||||
|
# income * 40% - 100,000 だが1円単位で計算
|
||||||
|
kojo = int(income * 0.4) - 100_000
|
||||||
|
elif income <= 3_600_000:
|
||||||
|
kojo = int(income * 0.3) + 80_000
|
||||||
|
elif income <= 6_600_000:
|
||||||
|
kojo = int(income * 0.2) + 440_000
|
||||||
|
elif income <= 8_500_000:
|
||||||
|
kojo = int(income * 0.1) + 1_100_000
|
||||||
|
else:
|
||||||
|
kojo = 1_950_000
|
||||||
|
|
||||||
|
shotoku = max(0, income - kojo)
|
||||||
|
return Decimal(str(shotoku))
|
||||||
|
|
||||||
|
|
||||||
|
def calc_basic_deduction(tax_year: int) -> Decimal:
|
||||||
|
"""
|
||||||
|
基礎控除額を計算する
|
||||||
|
令和7年(2025年)以降: 580,000円
|
||||||
|
令和2年(2020年)〜令和6年(2024年): 480,000円
|
||||||
|
"""
|
||||||
|
if tax_year >= 2025:
|
||||||
|
return Decimal("580000")
|
||||||
|
elif tax_year >= 2020:
|
||||||
|
return Decimal("480000")
|
||||||
|
else:
|
||||||
|
return Decimal("380000")
|
||||||
|
|
||||||
|
|
||||||
|
def get_age_at_year_end(birth_date: date, tax_year: int) -> int:
|
||||||
|
"""その年の12月31日時点の年齢を計算"""
|
||||||
|
year_end = date(tax_year, 12, 31)
|
||||||
|
age = year_end.year - birth_date.year
|
||||||
|
if (year_end.month, year_end.day) < (birth_date.month, birth_date.day):
|
||||||
|
age -= 1
|
||||||
|
return age
|
||||||
|
|
||||||
|
|
||||||
|
def calc_dependent_deduction(dep: Dict[str, Any], tax_year: int) -> tuple:
|
||||||
|
"""
|
||||||
|
扶養控除額を計算する
|
||||||
|
Returns: (deduction_type, deduction_amount)
|
||||||
|
"""
|
||||||
|
birth_date = dep.get("birth_date")
|
||||||
|
if birth_date is None:
|
||||||
|
return ("一般", Decimal("380000"))
|
||||||
|
|
||||||
|
age = get_age_at_year_end(birth_date, tax_year)
|
||||||
|
|
||||||
|
if age >= 70:
|
||||||
|
# 老人扶養親族 (同居老親等かどうかは不明なのでその他として扱う)
|
||||||
|
return ("老人扶養", Decimal("480000"))
|
||||||
|
elif 19 <= age <= 22:
|
||||||
|
# 特定扶養親族
|
||||||
|
return ("特定扶養", Decimal("630000"))
|
||||||
|
elif age >= 16:
|
||||||
|
# 一般扶養親族
|
||||||
|
return ("一般扶養", Decimal("380000"))
|
||||||
|
else:
|
||||||
|
# 16歳未満 - 控除なし
|
||||||
|
return ("年少", Decimal("0"))
|
||||||
|
|
||||||
|
|
||||||
|
def calc_spouse_deduction(spouse_income: Decimal, employee_income: Decimal, spouse_age: Optional[int]) -> tuple:
|
||||||
|
"""
|
||||||
|
配偶者控除・配偶者特別控除を計算する
|
||||||
|
Returns: (deduction_type, deduction_amount)
|
||||||
|
"""
|
||||||
|
spouse_inc = int(spouse_income)
|
||||||
|
emp_inc = int(employee_income)
|
||||||
|
|
||||||
|
# 納税者の合計所得金額が1,000万円超の場合は配偶者控除なし
|
||||||
|
# 給与所得として概算 (ここでは支払金額から控除後を算出)
|
||||||
|
emp_shotoku = int(calc_kyuyo_shotoku(employee_income))
|
||||||
|
if emp_shotoku > 10_000_000:
|
||||||
|
return (None, Decimal("0"))
|
||||||
|
|
||||||
|
# 配偶者の合計所得が48万円(収入103万円相当)以下 → 配偶者控除
|
||||||
|
if spouse_inc <= 480_000:
|
||||||
|
if spouse_age and spouse_age >= 70:
|
||||||
|
return ("老人配偶者控除", Decimal("480000"))
|
||||||
|
return ("配偶者控除", Decimal("380000"))
|
||||||
|
|
||||||
|
# 配偶者特別控除(配偶者の合計所得 48万1円〜133万円)
|
||||||
|
elif spouse_inc <= 1_330_000:
|
||||||
|
# 段階的な配偶者特別控除
|
||||||
|
if spouse_inc <= 950_000:
|
||||||
|
return ("配偶者特別控除", Decimal("380000"))
|
||||||
|
elif spouse_inc <= 1_000_000:
|
||||||
|
return ("配偶者特別控除", Decimal("360000"))
|
||||||
|
elif spouse_inc <= 1_050_000:
|
||||||
|
return ("配偶者特別控除", Decimal("310000"))
|
||||||
|
elif spouse_inc <= 1_100_000:
|
||||||
|
return ("配偶者特別控除", Decimal("260000"))
|
||||||
|
elif spouse_inc <= 1_150_000:
|
||||||
|
return ("配偶者特別控除", Decimal("210000"))
|
||||||
|
elif spouse_inc <= 1_200_000:
|
||||||
|
return ("配偶者特別控除", Decimal("160000"))
|
||||||
|
elif spouse_inc <= 1_250_000:
|
||||||
|
return ("配偶者特別控除", Decimal("110000"))
|
||||||
|
elif spouse_inc <= 1_300_000:
|
||||||
|
return ("配偶者特別控除", Decimal("60000"))
|
||||||
|
else:
|
||||||
|
return ("配偶者特別控除", Decimal("30000"))
|
||||||
|
else:
|
||||||
|
return (None, Decimal("0"))
|
||||||
|
|
||||||
|
|
||||||
|
def get_withholding_slip_data(tax_year: int, employee_id: int) -> Optional[WithholdingSlipData]:
|
||||||
|
"""
|
||||||
|
指定年度・従業員の源泉徴収票データを計算して返す
|
||||||
|
"""
|
||||||
|
with get_connection() as conn:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
# 1. 従業員情報を取得
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT employee_id, employee_code, name, name_kana,
|
||||||
|
address, birth_date, my_number
|
||||||
|
FROM employees
|
||||||
|
WHERE employee_id = %s
|
||||||
|
""",
|
||||||
|
(employee_id,)
|
||||||
|
)
|
||||||
|
emp = cur.fetchone()
|
||||||
|
if not emp:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 2. 月次給与データを集計(その年のすべての月)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as month_count,
|
||||||
|
COALESCE(SUM(total_payment), 0) as total_payment,
|
||||||
|
COALESCE(SUM(commute_allowance), 0) as total_commute,
|
||||||
|
COALESCE(SUM(health_insurance), 0) as health_ins,
|
||||||
|
COALESCE(SUM(care_insurance), 0) as care_ins,
|
||||||
|
COALESCE(SUM(pension_insurance), 0) as pension_ins,
|
||||||
|
COALESCE(SUM(employment_insurance), 0) as emp_ins,
|
||||||
|
COALESCE(SUM(income_tax), 0) as income_tax
|
||||||
|
FROM monthly_payroll
|
||||||
|
WHERE employee_id = %s
|
||||||
|
AND payroll_year = %s
|
||||||
|
""",
|
||||||
|
(employee_id, tax_year)
|
||||||
|
)
|
||||||
|
salary_summary = cur.fetchone()
|
||||||
|
|
||||||
|
# 3. 賞与データを集計(その年のすべての賞与)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as bonus_count,
|
||||||
|
COALESCE(SUM(total_bonus), 0) as total_bonus,
|
||||||
|
COALESCE(SUM(health_insurance), 0) as health_ins,
|
||||||
|
COALESCE(SUM(care_insurance), 0) as care_ins,
|
||||||
|
COALESCE(SUM(pension_insurance), 0) as pension_ins,
|
||||||
|
COALESCE(SUM(employment_insurance), 0) as emp_ins,
|
||||||
|
COALESCE(SUM(income_tax), 0) as income_tax
|
||||||
|
FROM bonus_payments
|
||||||
|
WHERE employee_id = %s
|
||||||
|
AND bonus_year = %s
|
||||||
|
""",
|
||||||
|
(employee_id, tax_year)
|
||||||
|
)
|
||||||
|
bonus_summary = cur.fetchone()
|
||||||
|
|
||||||
|
# 4. 扶養親族情報を取得(その年の12月31日時点で有効なもの)
|
||||||
|
cur.execute(
|
||||||
|
"""
|
||||||
|
SELECT d.dependent_id, d.name, d.relationship, d.birth_date,
|
||||||
|
d.is_spouse, d.is_disabled, d.income_amount, d.mynumber
|
||||||
|
FROM dependents d
|
||||||
|
WHERE d.employee_id = %s
|
||||||
|
AND d.valid_from <= %s
|
||||||
|
AND (d.valid_to IS NULL OR d.valid_to >= %s)
|
||||||
|
ORDER BY d.is_spouse DESC, d.birth_date
|
||||||
|
""",
|
||||||
|
(employee_id,
|
||||||
|
date(tax_year, 12, 31),
|
||||||
|
date(tax_year, 12, 31))
|
||||||
|
)
|
||||||
|
dep_rows = cur.fetchall()
|
||||||
|
|
||||||
|
# 5. 集計データを整理
|
||||||
|
s = salary_summary or {}
|
||||||
|
b = bonus_summary or {}
|
||||||
|
|
||||||
|
def to_dec(v) -> Decimal:
|
||||||
|
if v is None:
|
||||||
|
return Decimal("0")
|
||||||
|
return Decimal(str(v))
|
||||||
|
|
||||||
|
salary_total = to_dec(s.get("total_payment", 0))
|
||||||
|
salary_commute = to_dec(s.get("total_commute", 0))
|
||||||
|
bonus_total = to_dec(b.get("total_bonus", 0))
|
||||||
|
|
||||||
|
# 通勤手当の非課税額(月150,000円上限)は支払金額から除外
|
||||||
|
# 簡易計算:通勤手当全額を非課税として除外(厳密には上限チェックが必要)
|
||||||
|
commute_taxable = Decimal("0") # 通勤手当は非課税として全額除外
|
||||||
|
|
||||||
|
# 源泉徴収票の「支払金額」= 給与+賞与の総支給額(通勤手当を除く)
|
||||||
|
# 実務上は通勤手当を除いた金額を記載することが多い
|
||||||
|
total_pay_for_slip = salary_total - salary_commute + bonus_total
|
||||||
|
|
||||||
|
# 社会保険料合計
|
||||||
|
health_ins = to_dec(s.get("health_ins", 0)) + to_dec(b.get("health_ins", 0))
|
||||||
|
care_ins = to_dec(s.get("care_ins", 0)) + to_dec(b.get("care_ins", 0))
|
||||||
|
pension_ins = to_dec(s.get("pension_ins", 0)) + to_dec(b.get("pension_ins", 0))
|
||||||
|
emp_ins = to_dec(s.get("emp_ins", 0)) + to_dec(b.get("emp_ins", 0))
|
||||||
|
social_ins_total = health_ins + care_ins + pension_ins + emp_ins
|
||||||
|
|
||||||
|
# 源泉徴収税額合計
|
||||||
|
income_tax_total = to_dec(s.get("income_tax", 0)) + to_dec(b.get("income_tax", 0))
|
||||||
|
|
||||||
|
# 6. 給与所得控除後の金額を計算
|
||||||
|
kyuyo_shotoku = calc_kyuyo_shotoku(total_pay_for_slip)
|
||||||
|
|
||||||
|
# 7. 基礎控除
|
||||||
|
basic_deduction = calc_basic_deduction(tax_year)
|
||||||
|
|
||||||
|
# 8. 扶養親族の処理
|
||||||
|
birth_date_emp = emp.get("birth_date") if isinstance(emp, dict) else None
|
||||||
|
|
||||||
|
dependents_over16 = []
|
||||||
|
dependents_under16 = []
|
||||||
|
dependent_deduction_total = Decimal("0")
|
||||||
|
spouse_info = None
|
||||||
|
spouse_deduction_amount = Decimal("0")
|
||||||
|
has_spouse_deduction = False
|
||||||
|
spouse_deduction_type = None
|
||||||
|
|
||||||
|
for dep in dep_rows:
|
||||||
|
if isinstance(dep, dict):
|
||||||
|
d = dep
|
||||||
|
else:
|
||||||
|
d = {
|
||||||
|
"dependent_id": dep[0], "name": dep[1], "relationship": dep[2],
|
||||||
|
"birth_date": dep[3], "is_spouse": dep[4], "is_disabled": dep[5],
|
||||||
|
"income_amount": dep[6], "mynumber": dep[7]
|
||||||
|
}
|
||||||
|
|
||||||
|
dep_birth = d.get("birth_date")
|
||||||
|
dep_age = get_age_at_year_end(dep_birth, tax_year) if dep_birth else None
|
||||||
|
dep_income = to_dec(d.get("income_amount", 0))
|
||||||
|
|
||||||
|
if d.get("is_spouse"):
|
||||||
|
# 配偶者処理
|
||||||
|
spouse_age = dep_age
|
||||||
|
sp_type, sp_amount = calc_spouse_deduction(dep_income, total_pay_for_slip, spouse_age)
|
||||||
|
if sp_type:
|
||||||
|
has_spouse_deduction = True
|
||||||
|
spouse_deduction_amount = sp_amount
|
||||||
|
spouse_deduction_type = sp_type
|
||||||
|
spouse_deduction_total = sp_amount
|
||||||
|
else:
|
||||||
|
spouse_deduction_total = Decimal("0")
|
||||||
|
|
||||||
|
spouse_info = DependentInfo(
|
||||||
|
name=d.get("name", ""),
|
||||||
|
relationship="配偶者",
|
||||||
|
birth_date=str(dep_birth) if dep_birth else None,
|
||||||
|
is_spouse=True,
|
||||||
|
is_disabled=bool(d.get("is_disabled", False)),
|
||||||
|
income_amount=dep_income,
|
||||||
|
my_number=d.get("mynumber"),
|
||||||
|
deduction_type=sp_type,
|
||||||
|
deduction_amount=spouse_deduction_amount
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# 扶養親族
|
||||||
|
dep_type, dep_amount = calc_dependent_deduction(d, tax_year)
|
||||||
|
dep_obj = DependentInfo(
|
||||||
|
name=d.get("name", ""),
|
||||||
|
relationship=d.get("relationship", ""),
|
||||||
|
birth_date=str(dep_birth) if dep_birth else None,
|
||||||
|
is_spouse=False,
|
||||||
|
is_disabled=bool(d.get("is_disabled", False)),
|
||||||
|
income_amount=dep_income,
|
||||||
|
my_number=d.get("mynumber"),
|
||||||
|
deduction_type=dep_type,
|
||||||
|
deduction_amount=dep_amount
|
||||||
|
)
|
||||||
|
|
||||||
|
if dep_age is not None and dep_age < 16:
|
||||||
|
dependents_under16.append(dep_obj)
|
||||||
|
else:
|
||||||
|
dependents_over16.append(dep_obj)
|
||||||
|
dependent_deduction_total += dep_amount
|
||||||
|
|
||||||
|
# 9. 所得控除の額の合計額
|
||||||
|
total_deduction_amount = (
|
||||||
|
social_ins_total
|
||||||
|
+ basic_deduction
|
||||||
|
+ spouse_deduction_amount
|
||||||
|
+ dependent_deduction_total
|
||||||
|
)
|
||||||
|
|
||||||
|
# 10. 摘要(社会保険料の内訳)
|
||||||
|
remarks_parts = []
|
||||||
|
if health_ins > 0:
|
||||||
|
remarks_parts.append(f"健康保険料{int(health_ins):,}円")
|
||||||
|
if care_ins > 0:
|
||||||
|
remarks_parts.append(f"介護保険料{int(care_ins):,}円")
|
||||||
|
if pension_ins > 0:
|
||||||
|
remarks_parts.append(f"厚生年金{int(pension_ins):,}円")
|
||||||
|
if emp_ins > 0:
|
||||||
|
remarks_parts.append(f"雇用保険{int(emp_ins):,}円")
|
||||||
|
remarks = " ".join(remarks_parts) if remarks_parts else None
|
||||||
|
|
||||||
|
months_with_salary = int(s.get("month_count", 0)) if s else 0
|
||||||
|
months_with_bonus = int(b.get("bonus_count", 0)) if b else 0
|
||||||
|
|
||||||
|
return WithholdingSlipData(
|
||||||
|
employee_id=emp.get("employee_id") if isinstance(emp, dict) else emp[0],
|
||||||
|
employee_code=emp.get("employee_code", "") if isinstance(emp, dict) else emp[1],
|
||||||
|
employee_name=emp.get("name", "") if isinstance(emp, dict) else emp[2],
|
||||||
|
employee_name_kana=emp.get("name_kana") if isinstance(emp, dict) else emp[3],
|
||||||
|
employee_address=emp.get("address") if isinstance(emp, dict) else emp[4],
|
||||||
|
employee_birth_date=str(emp.get("birth_date")) if (isinstance(emp, dict) and emp.get("birth_date")) else None,
|
||||||
|
my_number=emp.get("my_number") if isinstance(emp, dict) else emp[6],
|
||||||
|
tax_year=tax_year,
|
||||||
|
total_payment=salary_total + bonus_total,
|
||||||
|
total_payment_excl_commute=total_pay_for_slip,
|
||||||
|
kyuyo_shotoku=kyuyo_shotoku,
|
||||||
|
social_insurance_total=social_ins_total,
|
||||||
|
health_insurance_total=health_ins,
|
||||||
|
care_insurance_total=care_ins,
|
||||||
|
pension_total=pension_ins,
|
||||||
|
employment_insurance_total=emp_ins,
|
||||||
|
basic_deduction=basic_deduction,
|
||||||
|
has_spouse_deduction=has_spouse_deduction,
|
||||||
|
spouse_deduction_amount=spouse_deduction_amount,
|
||||||
|
spouse_name=spouse_info.name if spouse_info else None,
|
||||||
|
spouse_name_kana=spouse_info.name_kana if spouse_info else None,
|
||||||
|
spouse_income=spouse_info.income_amount if spouse_info else Decimal("0"),
|
||||||
|
spouse_deduction_type=spouse_deduction_type,
|
||||||
|
dependents_over16=dependents_over16,
|
||||||
|
dependents_under16=dependents_under16,
|
||||||
|
dependent_deduction_total=dependent_deduction_total,
|
||||||
|
total_deduction_amount=total_deduction_amount,
|
||||||
|
income_tax_withheld=income_tax_total,
|
||||||
|
months_with_salary=months_with_salary,
|
||||||
|
months_with_bonus=months_with_bonus,
|
||||||
|
remarks=remarks,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_withholding_slips(tax_year: int, employee_ids: List[int]) -> List[WithholdingSlipData]:
|
||||||
|
"""複数従業員の源泉徴収票データを取得"""
|
||||||
|
results = []
|
||||||
|
for eid in employee_ids:
|
||||||
|
try:
|
||||||
|
slip = get_withholding_slip_data(tax_year, eid)
|
||||||
|
if slip:
|
||||||
|
results.append(slip)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"源泉徴収票計算エラー employee_id={eid}: {e}")
|
||||||
|
return results
|
||||||
@@ -110,6 +110,15 @@
|
|||||||
<h2>税率・保険料設定</h2>
|
<h2>税率・保険料設定</h2>
|
||||||
<p>社会保険料率、所得税率表の管理を行います</p>
|
<p>社会保険料率、所得税率表の管理を行います</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="menu-card"
|
||||||
|
onclick="location.href = 'withholding-slip.html'"
|
||||||
|
>
|
||||||
|
<div class="icon">📄</div>
|
||||||
|
<h2>源泉徴収票</h2>
|
||||||
|
<p>給与所得の源泉徴収票を発行します(本人用・区役所用・税務署用 各1部)</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -135,6 +144,10 @@
|
|||||||
<strong>月次給与計算</strong
|
<strong>月次給与計算</strong
|
||||||
>で勤怠情報を入力し、給与を計算・承認します
|
>で勤怠情報を入力し、給与を計算・承認します
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong>源泉徴収票</strong
|
||||||
|
>で年末に給与所得の源泉徴収票(本人・区役所・税務署の3部)を発行します
|
||||||
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
586
frontend/withholding-slip.html
Normal file
586
frontend/withholding-slip.html
Normal file
@@ -0,0 +1,586 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
|
||||||
|
<meta http-equiv="Pragma" content="no-cache" />
|
||||||
|
<meta http-equiv="Expires" content="0" />
|
||||||
|
<script src="js/auth.js"></script>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>給与所得の源泉徴収票</title>
|
||||||
|
<link rel="stylesheet" href="/css/style.css" />
|
||||||
|
<style>
|
||||||
|
/* ===== 画面表示用スタイル ===== */
|
||||||
|
body { font-family: 'MS Gothic', 'Meiryo', sans-serif; }
|
||||||
|
.container { max-width: 1200px; margin: 0 auto; padding: 0 20px; }
|
||||||
|
.form-panel {
|
||||||
|
background: #f9f9f9;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.form-row { display: flex; flex-wrap: wrap; gap: 16px; align-items: flex-end; }
|
||||||
|
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
||||||
|
.form-group label { font-size: 13px; font-weight: bold; }
|
||||||
|
.form-group input, .form-group select {
|
||||||
|
padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; font-size: 14px;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 8px 18px; border: none; border-radius: 4px;
|
||||||
|
cursor: pointer; font-size: 14px; font-weight: bold;
|
||||||
|
}
|
||||||
|
.btn-primary { background: #007bff; color: white; }
|
||||||
|
.btn-primary:hover { background: #0056b3; }
|
||||||
|
.btn-success { background: #28a745; color: white; }
|
||||||
|
.btn-success:hover { background: #1e7e34; }
|
||||||
|
.btn-secondary { background: #6c757d; color: white; }
|
||||||
|
.employee-list {
|
||||||
|
margin: 8px 0;
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.employee-chip {
|
||||||
|
padding: 4px 10px;
|
||||||
|
background: #e9ecef;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
}
|
||||||
|
.employee-chip.selected {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #0056b3;
|
||||||
|
}
|
||||||
|
#slip-area { margin-top: 20px; }
|
||||||
|
.alert { padding: 12px 16px; border-radius: 4px; margin-bottom: 16px; }
|
||||||
|
.alert-info { background: #d1ecf1; color: #0c5460; border: 1px solid #bee5eb; }
|
||||||
|
.alert-warning { background: #fff3cd; color: #856404; border: 1px solid #ffeeba; }
|
||||||
|
|
||||||
|
/* ===== 印刷プレビューエリア ===== */
|
||||||
|
.slip-page {
|
||||||
|
background: white;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid #333;
|
||||||
|
max-width: 960px;
|
||||||
|
}
|
||||||
|
.no-print { /* 印刷時に非表示にするクラス */ }
|
||||||
|
|
||||||
|
/* ===== 源泉徴収票 票面スタイル ===== */
|
||||||
|
.withholding-slip {
|
||||||
|
font-family: 'MS Gothic', 'Meiryo', 'MS Mincho', monospace;
|
||||||
|
font-size: 9pt;
|
||||||
|
border: 1.5px solid #000;
|
||||||
|
padding: 4px;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
.slip-copy-label {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 8pt;
|
||||||
|
color: #555;
|
||||||
|
padding-bottom: 1px;
|
||||||
|
border-bottom: 1px solid #888;
|
||||||
|
margin-bottom: 3px;
|
||||||
|
}
|
||||||
|
.slip-title {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 13pt;
|
||||||
|
font-weight: bold;
|
||||||
|
border: none;
|
||||||
|
padding: 2px 0;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
}
|
||||||
|
.slip-year {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 10pt;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.slip-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
.slip-table td, .slip-table th {
|
||||||
|
border: 1px solid #333;
|
||||||
|
padding: 2px 4px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
.slip-table .header-cell {
|
||||||
|
background: #f0f0f0;
|
||||||
|
font-size: 7.5pt;
|
||||||
|
text-align: center;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.slip-table .amount-cell {
|
||||||
|
text-align: right;
|
||||||
|
font-size: 9pt;
|
||||||
|
min-width: 80px;
|
||||||
|
}
|
||||||
|
.slip-table .label-cell {
|
||||||
|
font-size: 7.5pt;
|
||||||
|
background: #f8f8f8;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.slip-table .value-cell {
|
||||||
|
font-size: 9pt;
|
||||||
|
min-width: 100px;
|
||||||
|
}
|
||||||
|
.row-payer {
|
||||||
|
margin-top: 4px;
|
||||||
|
border: 1px solid #000;
|
||||||
|
padding: 3px 6px;
|
||||||
|
font-size: 8.5pt;
|
||||||
|
}
|
||||||
|
.slip-divider {
|
||||||
|
border: none;
|
||||||
|
border-top: 2px dashed #aaa;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 印刷時スタイル ===== */
|
||||||
|
@media print {
|
||||||
|
@page {
|
||||||
|
size: A4 portrait;
|
||||||
|
margin: 8mm 6mm;
|
||||||
|
}
|
||||||
|
body { margin: 0; padding: 0; }
|
||||||
|
.no-print { display: none !important; }
|
||||||
|
.slip-page {
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
.withholding-slip {
|
||||||
|
font-size: 8pt;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
.slip-divider { margin: 4px 0; }
|
||||||
|
.page-break { page-break-before: always; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container no-print">
|
||||||
|
<button onclick="location.href='payroll.html'" class="btn btn-secondary" style="margin: 12px 0;">
|
||||||
|
← 給与管理メニューに戻る
|
||||||
|
</button>
|
||||||
|
<h1>給与所得の源泉徴収票</h1>
|
||||||
|
|
||||||
|
<!-- 設定フォーム -->
|
||||||
|
<div class="form-panel">
|
||||||
|
<h3 style="margin-top:0">① 発行条件の設定</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>対象年(令和換算)</label>
|
||||||
|
<select id="tax-year" style="width:120px">
|
||||||
|
<!-- JSで動的生成 -->
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>従業員を選択(複数可)</label>
|
||||||
|
<button class="btn btn-secondary" onclick="toggleAllEmployees()" style="font-size:12px;padding:4px 10px">全選択 / 全解除</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="employee-list" class="employee-list" style="margin-top:10px">
|
||||||
|
<span style="color:#999">読み込み中...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<hr style="margin: 16px 0" />
|
||||||
|
<h3 style="margin-top:0">② 支払者情報(源泉徴収票下部に印刷)</h3>
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>会社名(氏名又は名称)</label>
|
||||||
|
<input type="text" id="company-name" placeholder="株式会社〇〇" style="width:220px" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>住所・所在地</label>
|
||||||
|
<input type="text" id="company-address" placeholder="〒000-0000 東京都..." style="width:280px" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>電話番号</label>
|
||||||
|
<input type="text" id="company-phone" placeholder="03-0000-0000" style="width:150px" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>法人番号(任意)</label>
|
||||||
|
<input type="text" id="company-mynumber" placeholder="1234567890123" style="width:160px" maxlength="13" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top: 16px; display:flex; gap:12px; align-items:center">
|
||||||
|
<button class="btn btn-primary" onclick="generateSlips()">📋 源泉徴収票を作成</button>
|
||||||
|
<button class="btn btn-success" onclick="printSlips()" id="print-btn" style="display:none">🖨️ 印刷(3部)</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="alert-area"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 印刷対象エリア -->
|
||||||
|
<div id="slip-area"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API_BASE = '';
|
||||||
|
|
||||||
|
// 年度セレクタ初期化
|
||||||
|
function initYearSelect() {
|
||||||
|
const sel = document.getElementById('tax-year');
|
||||||
|
const currentYear = new Date().getFullYear();
|
||||||
|
for (let y = currentYear; y >= currentYear - 5; y--) {
|
||||||
|
const reiwa = y - 2018;
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = y;
|
||||||
|
opt.textContent = `令和${reiwa}年(${y}年)`;
|
||||||
|
if (y === currentYear - 1) opt.selected = true; // デフォルト前年
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 従業員リストを読み込む
|
||||||
|
async function loadEmployees() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/payroll/withholding-slip/employees`, {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + (localStorage.getItem('token') || '') }
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to load employees');
|
||||||
|
const data = await resp.json();
|
||||||
|
renderEmployeeChips(data.employees || []);
|
||||||
|
} catch (e) {
|
||||||
|
document.getElementById('employee-list').innerHTML =
|
||||||
|
'<span style="color:red">従業員リストの取得に失敗しました</span>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let allEmployees = [];
|
||||||
|
|
||||||
|
function renderEmployeeChips(employees) {
|
||||||
|
allEmployees = employees;
|
||||||
|
const container = document.getElementById('employee-list');
|
||||||
|
if (!employees.length) {
|
||||||
|
container.innerHTML = '<span style="color:#999">従業員が登録されていません</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.innerHTML = '';
|
||||||
|
employees.forEach(emp => {
|
||||||
|
const chip = document.createElement('div');
|
||||||
|
chip.className = 'employee-chip selected';
|
||||||
|
chip.dataset.id = emp.id;
|
||||||
|
chip.textContent = `${emp.code} ${emp.name}`;
|
||||||
|
chip.title = emp.name_kana || '';
|
||||||
|
chip.onclick = () => chip.classList.toggle('selected');
|
||||||
|
container.appendChild(chip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAllEmployees() {
|
||||||
|
const chips = document.querySelectorAll('.employee-chip');
|
||||||
|
const allSelected = [...chips].every(c => c.classList.contains('selected'));
|
||||||
|
chips.forEach(c => {
|
||||||
|
if (allSelected) c.classList.remove('selected');
|
||||||
|
else c.classList.add('selected');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedEmployeeIds() {
|
||||||
|
return [...document.querySelectorAll('.employee-chip.selected')]
|
||||||
|
.map(c => parseInt(c.dataset.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlert(msg, type = 'info') {
|
||||||
|
document.getElementById('alert-area').innerHTML =
|
||||||
|
`<div class="alert alert-${type}">${msg}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 源泉徴収票データを取得・表示
|
||||||
|
async function generateSlips() {
|
||||||
|
const taxYear = parseInt(document.getElementById('tax-year').value);
|
||||||
|
const empIds = getSelectedEmployeeIds();
|
||||||
|
if (!empIds.length) {
|
||||||
|
showAlert('従業員を1名以上選択してください', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const companyName = document.getElementById('company-name').value || '(未設定)';
|
||||||
|
const companyAddress = document.getElementById('company-address').value || '';
|
||||||
|
const companyPhone = document.getElementById('company-phone').value || '';
|
||||||
|
const companyMyNumber = document.getElementById('company-mynumber').value || '';
|
||||||
|
|
||||||
|
showAlert('生成中...', 'info');
|
||||||
|
document.getElementById('slip-area').innerHTML = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/payroll/withholding-slip/generate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': 'Bearer ' + (localStorage.getItem('token') || '')
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tax_year: taxYear,
|
||||||
|
employee_ids: empIds,
|
||||||
|
company_name: companyName,
|
||||||
|
company_address: companyAddress,
|
||||||
|
company_phone: companyPhone,
|
||||||
|
company_my_number: companyMyNumber
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.json();
|
||||||
|
throw new Error(err.detail || '取得エラー');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.slips || data.slips.length === 0) {
|
||||||
|
showAlert(`令和${taxYear - 2018}年(${taxYear}年)の給与データがありません`, 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showAlert(`${data.total_count}名分の源泉徴収票を生成しました(各3部)`, 'info');
|
||||||
|
renderAllSlips(data.slips, taxYear, { companyName, companyAddress, companyPhone, companyMyNumber });
|
||||||
|
document.getElementById('print-btn').style.display = '';
|
||||||
|
} catch (e) {
|
||||||
|
showAlert('エラー: ' + e.message, 'warning');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全従業員分の票面を描画
|
||||||
|
function renderAllSlips(slips, taxYear, payer) {
|
||||||
|
const area = document.getElementById('slip-area');
|
||||||
|
area.innerHTML = '';
|
||||||
|
|
||||||
|
slips.forEach((slip, idx) => {
|
||||||
|
const page = document.createElement('div');
|
||||||
|
page.className = 'slip-page' + (idx > 0 ? ' page-break' : '');
|
||||||
|
|
||||||
|
// 3部(市区町村提出用、受給者交付用、税務署提出用)
|
||||||
|
const copies = [
|
||||||
|
{ label: '市区町村提出用', note: '市区町村へ提出してください' },
|
||||||
|
{ label: '受給者交付用', note: '受給者(本人)に交付してください' },
|
||||||
|
{ label: '税務署提出用', note: '税務署へ提出してください' },
|
||||||
|
];
|
||||||
|
|
||||||
|
copies.forEach((copy, ci) => {
|
||||||
|
if (ci > 0) {
|
||||||
|
const div = document.createElement('hr');
|
||||||
|
div.className = 'slip-divider';
|
||||||
|
page.appendChild(div);
|
||||||
|
}
|
||||||
|
page.appendChild(buildSlipHTML(slip, taxYear, payer, copy.label, copy.note));
|
||||||
|
});
|
||||||
|
|
||||||
|
area.appendChild(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 票面HTML要素を生成
|
||||||
|
function buildSlipHTML(slip, taxYear, payer, copyLabel, copyNote) {
|
||||||
|
const reiwa = taxYear - 2018;
|
||||||
|
const fmt = n => parseInt(n || 0).toLocaleString('ja-JP');
|
||||||
|
const fmtBlank = n => parseInt(n || 0) === 0 ? '' : parseInt(n).toLocaleString('ja-JP');
|
||||||
|
|
||||||
|
const wrap = document.createElement('div');
|
||||||
|
wrap.className = 'withholding-slip';
|
||||||
|
|
||||||
|
// コピーラベル
|
||||||
|
const labelDiv = document.createElement('div');
|
||||||
|
labelDiv.className = 'slip-copy-label';
|
||||||
|
labelDiv.textContent = `(${copyLabel})`;
|
||||||
|
wrap.appendChild(labelDiv);
|
||||||
|
|
||||||
|
// タイトル
|
||||||
|
const titleDiv = document.createElement('div');
|
||||||
|
titleDiv.className = 'slip-year';
|
||||||
|
titleDiv.textContent = `令和 ${reiwa} 年分 給与所得の源泉徴収票`;
|
||||||
|
titleDiv.style.cssText = 'text-align:center;font-size:11pt;font-weight:bold;letter-spacing:2px;margin-bottom:4px;';
|
||||||
|
wrap.appendChild(titleDiv);
|
||||||
|
|
||||||
|
// --- 受給者情報行 ---
|
||||||
|
const recvTable = document.createElement('table');
|
||||||
|
recvTable.className = 'slip-table';
|
||||||
|
recvTable.style.marginBottom = '3px';
|
||||||
|
recvTable.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td rowspan="4" class="label-cell" style="width:70px;text-align:center;vertical-align:middle">支払を<br>受ける者</td>
|
||||||
|
<td class="label-cell" style="width:80px">住所又は居所</td>
|
||||||
|
<td colspan="3">${escHtml(slip.employee_address || ' ')}</td>
|
||||||
|
<td class="label-cell" style="width:60px">受給者番号</td>
|
||||||
|
<td style="width:80px">${escHtml(slip.employee_code || '')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell">個人番号</td>
|
||||||
|
<td colspan="3">${copyLabel === '受給者交付用' ? '※個人番号は記載しません' : maskMyNumber(slip.my_number)}</td>
|
||||||
|
<td class="label-cell">役職名</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell">(フリガナ)</td>
|
||||||
|
<td colspan="5">${escHtml(slip.employee_name_kana || ' ')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell">氏名</td>
|
||||||
|
<td colspan="5" style="font-size:11pt;font-weight:bold">${escHtml(slip.employee_name || '')}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(recvTable);
|
||||||
|
|
||||||
|
// --- メイン金額行 ---
|
||||||
|
const mainTable = document.createElement('table');
|
||||||
|
mainTable.className = 'slip-table';
|
||||||
|
mainTable.style.marginBottom = '3px';
|
||||||
|
|
||||||
|
const paymentAmt = fmt(slip.total_payment_excl_commute);
|
||||||
|
const shotokuAmt = fmt(slip.kyuyo_shotoku);
|
||||||
|
const deductionAmt = fmt(slip.total_deduction_amount);
|
||||||
|
const taxAmt = fmt(slip.income_tax_withheld);
|
||||||
|
|
||||||
|
mainTable.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td class="header-cell" style="width:10%">種別</td>
|
||||||
|
<td class="header-cell" style="width:22%">支払金額</td>
|
||||||
|
<td class="header-cell" style="width:22%">給与所得控除後の金額<br><small>(調整控除後)</small></td>
|
||||||
|
<td class="header-cell" style="width:22%">所得控除の額の合計額</td>
|
||||||
|
<td class="header-cell" style="width:22%">源泉徴収税額</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;font-size:8pt">給料・賞与</td>
|
||||||
|
<td class="amount-cell">${paymentAmt} 円</td>
|
||||||
|
<td class="amount-cell">${shotokuAmt} 円</td>
|
||||||
|
<td class="amount-cell">${deductionAmt} 円</td>
|
||||||
|
<td class="amount-cell">${taxAmt} 円</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(mainTable);
|
||||||
|
|
||||||
|
// --- 控除情報行 ---
|
||||||
|
const spouseMark = slip.has_spouse_deduction ? '有' : '-';
|
||||||
|
const spouseAmt = slip.has_spouse_deduction ? fmt(slip.spouse_deduction_amount) : ' ';
|
||||||
|
const dep16Count = slip.dependents_over16 ? slip.dependents_over16.length : 0;
|
||||||
|
const depUnder16Count = slip.dependents_under16 ? slip.dependents_under16.length : 0;
|
||||||
|
|
||||||
|
const deductTable = document.createElement('table');
|
||||||
|
deductTable.className = 'slip-table';
|
||||||
|
deductTable.style.marginBottom = '3px';
|
||||||
|
deductTable.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td class="header-cell" colspan="2">(源泉)控除対象配偶者<br>の有無等</td>
|
||||||
|
<td class="header-cell">配偶者(特別)<br>控除の額</td>
|
||||||
|
<td class="header-cell">控除対象扶養親族の数<br><small>(配偶者を除く。)</small></td>
|
||||||
|
<td class="header-cell">16歳未満<br>扶養親族の数</td>
|
||||||
|
<td class="header-cell">社会保険料等<br>の金額</td>
|
||||||
|
<td class="header-cell">基礎控除の額</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="text-align:center;width:40px">${spouseMark}</td>
|
||||||
|
<td style="font-size:7.5pt;width:40px">${slip.spouse_deduction_type || ' '}</td>
|
||||||
|
<td class="amount-cell">${spouseAmt ? spouseAmt + ' 円' : ' '}</td>
|
||||||
|
<td style="text-align:center">${dep16Count > 0 ? dep16Count + ' 人' : '-'}</td>
|
||||||
|
<td style="text-align:center">${depUnder16Count > 0 ? depUnder16Count + ' 人' : '-'}</td>
|
||||||
|
<td class="amount-cell">${fmt(slip.social_insurance_total)} 円</td>
|
||||||
|
<td class="amount-cell">${fmt(slip.basic_deduction)} 円</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(deductTable);
|
||||||
|
|
||||||
|
// --- 摘要行 ---
|
||||||
|
const remarksTable = document.createElement('table');
|
||||||
|
remarksTable.className = 'slip-table';
|
||||||
|
remarksTable.style.marginBottom = '3px';
|
||||||
|
remarksTable.innerHTML = `
|
||||||
|
<tr>
|
||||||
|
<td class="header-cell" style="width:60px">(摘要)</td>
|
||||||
|
<td style="font-size:8pt;padding:3px 6px">${escHtml(slip.remarks || ' ')}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(remarksTable);
|
||||||
|
|
||||||
|
// --- 扶養親族情報(最大4名)---
|
||||||
|
if ((slip.dependents_over16 && slip.dependents_over16.length > 0) ||
|
||||||
|
(slip.dependents_under16 && slip.dependents_under16.length > 0)) {
|
||||||
|
const depTable = document.createElement('table');
|
||||||
|
depTable.className = 'slip-table';
|
||||||
|
depTable.style.marginBottom = '3px';
|
||||||
|
depTable.innerHTML = '<tr><td class="header-cell" colspan="5">扶養親族情報</td></tr>';
|
||||||
|
|
||||||
|
const allDeps = [
|
||||||
|
...((slip.dependents_over16 || []).slice(0, 4)),
|
||||||
|
...((slip.dependents_under16 || []).slice(0, 2))
|
||||||
|
];
|
||||||
|
const headerRow = document.createElement('tr');
|
||||||
|
headerRow.innerHTML = `
|
||||||
|
<td class="header-cell">区分</td>
|
||||||
|
<td class="header-cell">氏名</td>
|
||||||
|
<td class="header-cell">続柄</td>
|
||||||
|
<td class="header-cell">生年月日</td>
|
||||||
|
<td class="header-cell">控除種別</td>
|
||||||
|
`;
|
||||||
|
depTable.appendChild(headerRow);
|
||||||
|
|
||||||
|
allDeps.forEach(dep => {
|
||||||
|
const row = document.createElement('tr');
|
||||||
|
row.innerHTML = `
|
||||||
|
<td style="text-align:center;font-size:7.5pt">${dep.is_spouse ? '配偶者' : '扶養'}</td>
|
||||||
|
<td>${escHtml(dep.name || '')}</td>
|
||||||
|
<td style="text-align:center">${escHtml(dep.relationship || '')}</td>
|
||||||
|
<td style="font-size:7.5pt">${escHtml(dep.birth_date || '')}</td>
|
||||||
|
<td style="font-size:7.5pt">${escHtml(dep.deduction_type || '')}</td>
|
||||||
|
`;
|
||||||
|
depTable.appendChild(row);
|
||||||
|
});
|
||||||
|
wrap.appendChild(depTable);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 支払者情報 ---
|
||||||
|
const payerDiv = document.createElement('div');
|
||||||
|
payerDiv.className = 'row-payer';
|
||||||
|
payerDiv.innerHTML = `
|
||||||
|
<table class="slip-table" style="border:none">
|
||||||
|
<tr>
|
||||||
|
<td class="label-cell" style="border:none;width:50px">支払者</td>
|
||||||
|
<td class="label-cell" style="border:none;width:60px">住所・所在地</td>
|
||||||
|
<td style="border:none">${escHtml(payer.companyAddress || ' ')}</td>
|
||||||
|
<td class="label-cell" style="border:none;width:50px">電話</td>
|
||||||
|
<td style="border:none;width:120px">${escHtml(payer.companyPhone || ' ')}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td style="border:none"></td>
|
||||||
|
<td class="label-cell" style="border:none">氏名又は名称</td>
|
||||||
|
<td style="border:none;font-size:10pt;font-weight:bold" colspan="3">${escHtml(payer.companyName || ' ')}</td>
|
||||||
|
</tr>
|
||||||
|
${payer.companyMyNumber ? `<tr><td style="border:none"></td><td class="label-cell" style="border:none">法人番号</td><td style="border:none" colspan="3">${escHtml(payer.companyMyNumber)}</td></tr>` : ''}
|
||||||
|
</table>
|
||||||
|
`;
|
||||||
|
wrap.appendChild(payerDiv);
|
||||||
|
|
||||||
|
return wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escHtml(str) {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function maskMyNumber(mn) {
|
||||||
|
if (!mn) return ' ';
|
||||||
|
// 税務署提出用は個人番号を表示(本来は管理が必要)
|
||||||
|
return mn;
|
||||||
|
}
|
||||||
|
|
||||||
|
function printSlips() {
|
||||||
|
window.print();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初期化
|
||||||
|
initYearSelect();
|
||||||
|
loadEmployees();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user