帳票出力作成

This commit is contained in:
admin
2026-02-01 20:13:52 +09:00
parent 0ac598ce2b
commit a2d7b54cd3
9 changed files with 2352 additions and 72 deletions

View File

@@ -102,6 +102,9 @@ app.include_router(payroll_calculation_router)
from app.payroll.bonus.router import router as payroll_bonus_router
app.include_router(payroll_bonus_router)
from app.payroll.vouchers.router import router as payroll_vouchers_router
app.include_router(payroll_vouchers_router)
from fastapi.staticfiles import StaticFiles
import os

View File

@@ -0,0 +1,2 @@
# Vouchers Module
# 账票出力機能 - 給与・賞与の出力管理

View File

@@ -0,0 +1,92 @@
from fastapi import APIRouter, HTTPException, Query
from typing import List
from .schemas import VoucherExportRequest, VoucherExportResponse, VoucherDataSummary
from .service import export_vouchers, get_employees_list
import logging
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/payroll/vouchers", tags=["vouchers"])
@router.get("/employees")
def get_employees():
"""従業員リストを取得"""
try:
employees = get_employees_list()
return {
"total": len(employees),
"employees": employees,
"status": "success"
}
except Exception as e:
logger.error(f"従業員リスト取得エラー: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/export")
def export_vouchers_data(request: VoucherExportRequest) -> VoucherExportResponse:
"""账票エクスポートプレビュー、CSV、PDF"""
try:
result = export_vouchers(request)
return result
except ValueError as e:
logger.error(f"バリデーションエラー: {str(e)}")
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"エクスポートエラー: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/data/salary")
def get_salary_data(
employee_id: int = Query(...),
start_year: int = Query(...),
start_month: int = Query(...),
end_year: int = Query(...),
end_month: int = Query(...)
):
"""単一従業員の給与データ取得"""
try:
from .service import get_salary_data_for_period
data = get_salary_data_for_period(
employee_id,
start_year,
start_month,
end_year,
end_month
)
return {
"status": "success",
"data": data
}
except Exception as e:
logger.error(f"給与データ取得エラー: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/data/bonus")
def get_bonus_data(
employee_id: int = Query(...),
start_year: int = Query(...),
start_month: int = Query(...),
end_year: int = Query(...),
end_month: int = Query(...)
):
"""単一従業員の賞与データ取得"""
try:
from .service import get_bonus_data_for_period
data = get_bonus_data_for_period(
employee_id,
start_year,
start_month,
end_year,
end_month
)
return {
"status": "success",
"data": data
}
except Exception as e:
logger.error(f"賞与データ取得エラー: {str(e)}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -0,0 +1,150 @@
from pydantic import BaseModel, Field
from typing import List, Optional, Dict, Any
from decimal import Decimal
class VoucherExportRequest(BaseModel):
"""账票エクスポート要求"""
start_year: int = Field(..., ge=2000, le=2099)
start_month: int = Field(..., ge=1, le=12)
end_year: int = Field(default=None, ge=2000, le=2099)
end_month: int = Field(default=None, ge=1, le=12)
employee_ids: List[int] = Field(..., min_items=1)
voucher_type: str = Field(default="all", pattern="^(all|salary|bonus)$")
format: str = Field(default="preview", pattern="^(preview|csv|pdf)$")
class Config:
json_schema_extra = {
"example": {
"start_year": 2026,
"start_month": 1,
"end_year": 2026,
"end_month": 3,
"employee_ids": [1, 2],
"voucher_type": "all",
"format": "preview"
}
}
class VoucherDataSummary(BaseModel):
"""従業員ごとの个票摘要"""
employee_id: int
employee_code: str
employee_name: str
has_salary_data: bool
has_bonus_data: bool
salary_count: int = 0
bonus_count: int = 0
salary_months: List[str] = Field(default_factory=list)
bonus_months: List[str] = Field(default_factory=list)
class Config:
from_attributes = True
class SalaryVoucherData(BaseModel):
"""給与個票"""
employee_id: int
employee_code: str
employee_name: str
payroll_year: int
payroll_month: int
# 勤怠情報
working_days: Decimal = Decimal(0)
working_hours: Decimal = Decimal(0)
overtime_hours: Decimal = Decimal(0)
holiday_hours: Decimal = Decimal(0)
late_hours: Decimal = Decimal(0)
absent_days: Decimal = Decimal(0)
# 支給項目
base_salary: Decimal = Decimal(0)
overtime_pay: Decimal = Decimal(0)
holiday_pay: Decimal = Decimal(0)
commute_allowance: Decimal = Decimal(0)
other_allowance: Decimal = Decimal(0)
total_payment: Decimal = Decimal(0)
# 控除項目
health_insurance: Decimal = Decimal(0)
care_insurance: Decimal = Decimal(0)
pension_insurance: Decimal = Decimal(0)
employment_insurance: Decimal = Decimal(0)
income_tax: Decimal = Decimal(0)
resident_tax: Decimal = Decimal(0)
other_deduction: Decimal = Decimal(0)
total_deduction: Decimal = Decimal(0)
# 差引支給額
net_payment: Decimal = Decimal(0)
# レガシー互換性用
gross_salary: Decimal = Decimal(0)
total_deductions: Decimal = Decimal(0)
net_salary: Decimal = Decimal(0)
class Config:
from_attributes = True
class BonusVoucherData(BaseModel):
"""賞与個票"""
employee_id: int
employee_code: str
employee_name: str
bonus_year: int
bonus_month: int
bonus_type: str = "" # 夏季賞与/冬季賞与/決算賞与/その他
# 支給項目
base_bonus: Decimal = Decimal(0) # 基本賞与額
performance_bonus: Decimal = Decimal(0) # 業績賞与
other_bonus: Decimal = Decimal(0) # その他賞与
total_bonus: Decimal = Decimal(0) # 総支給額
# 控除項目
health_insurance: Decimal = Decimal(0)
care_insurance: Decimal = Decimal(0)
pension_insurance: Decimal = Decimal(0)
employment_insurance: Decimal = Decimal(0)
income_tax: Decimal = Decimal(0) # 賞与所得税
other_deduction: Decimal = Decimal(0)
total_deduction: Decimal = Decimal(0)
# 差引支給額
net_bonus: Decimal = Decimal(0)
# レガシー互換性用
bonus_amount: Decimal = Decimal(0)
tax_amount: Decimal = Decimal(0)
class Config:
from_attributes = True
class VoucherExportResponse(BaseModel):
"""账票エクスポート応答"""
status: str = Field(..., pattern="^(success|no_data|partial_data)$")
has_data: bool
message: str
summary: List[VoucherDataSummary] = Field(default_factory=list)
data: Dict[str, Any] = Field(default_factory=dict)
class Config:
json_schema_extra = {
"example": {
"status": "success",
"has_data": True,
"message": "✓ 成功查詢: 2位従業員有數據",
"summary": [
{
"employee_id": 1,
"employee_code": "E001",
"employee_name": "田中太郎",
"has_salary_data": True,
"has_bonus_data": False,
"salary_count": 3,
"bonus_count": 0,
"salary_months": ["2026-01", "2026-02", "2026-03"],
"bonus_months": []
}
],
"data": {
"salary": [],
"bonus": []
}
}
}

View File

@@ -0,0 +1,419 @@
import asyncio
from typing import List, Dict, Any, Optional
from decimal import Decimal
import logging
from datetime import datetime
from .schemas import (
VoucherExportRequest, VoucherExportResponse, VoucherDataSummary,
SalaryVoucherData, BonusVoucherData
)
from app.core.database import get_connection
logger = logging.getLogger(__name__)
def get_employees_list() -> List[Dict[str, Any]]:
"""アクティブな従業員リストを取得"""
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
SELECT employee_id, employee_code, name
FROM employees
WHERE is_active = true
ORDER BY employee_code
""")
rows = cur.fetchall()
result = []
for row in rows:
# row が辞書の場合と、タプル/リストの場合の両方に対応
if isinstance(row, dict):
result.append({
"id": row.get("employee_id"),
"code": row.get("employee_code"),
"name": row.get("name")
})
else:
# タプル/リストの場合
result.append({
"id": row[0],
"code": row[1],
"name": row[2]
})
logger.info(f"従業員リスト取得: {len(result)}")
return result
except Exception as e:
logger.error(f"従業員リスト取得エラー: {str(e)}")
return []
def validate_date_range(
start_year: int, start_month: int,
end_year: Optional[int] = None, end_month: Optional[int] = None
) -> tuple:
"""日付範囲を検証・正規化"""
# 終了日が指定されない場合は開始日と同じ
if end_year is None or end_month is None:
end_year, end_month = start_year, start_month
# 範囲の妥当性チェック
start_ym = start_year * 100 + start_month
end_ym = end_year * 100 + end_month
if start_ym > end_ym:
raise ValueError(f"開始年月は終了年月より前である必要があります")
return (start_year, start_month, end_year, end_month)
def get_salary_data_for_period(
employee_id: int,
start_year: int, start_month: int,
end_year: int, end_month: int
) -> List[SalaryVoucherData]:
"""期間の給与データを取得"""
try:
with get_connection() as conn:
with conn.cursor() as cur:
start_ym = start_year * 100 + start_month
end_ym = end_year * 100 + end_month
cur.execute("""
SELECT
e.employee_id, e.employee_code, e.name,
mp.payroll_year, mp.payroll_month,
mp.working_days, mp.working_hours, mp.overtime_hours, mp.holiday_hours, mp.late_hours, mp.absent_days,
mp.base_salary, mp.overtime_pay, mp.holiday_pay, mp.commute_allowance, mp.other_allowance, mp.total_payment,
mp.health_insurance, mp.care_insurance, mp.pension_insurance, mp.employment_insurance,
mp.income_tax, mp.resident_tax, mp.other_deduction, mp.total_deduction,
mp.net_payment
FROM employees e
JOIN monthly_payroll mp ON e.employee_id = mp.employee_id
WHERE e.employee_id = %s
AND (mp.payroll_year * 100 + mp.payroll_month) >= %s
AND (mp.payroll_year * 100 + mp.payroll_month) <= %s
ORDER BY mp.payroll_year, mp.payroll_month
""", (employee_id, start_ym, end_ym))
rows = cur.fetchall()
data = []
for row in rows:
# Handle both dict and tuple formats
if isinstance(row, dict):
salary_obj = SalaryVoucherData(
employee_id=row.get("employee_id"),
employee_code=row.get("employee_code"),
employee_name=row.get("name"),
payroll_year=row.get("payroll_year"),
payroll_month=row.get("payroll_month"),
# 勤怠情報
working_days=Decimal(str(row.get("working_days", 0))) if row.get("working_days") else Decimal(0),
working_hours=Decimal(str(row.get("working_hours", 0))) if row.get("working_hours") else Decimal(0),
overtime_hours=Decimal(str(row.get("overtime_hours", 0))) if row.get("overtime_hours") else Decimal(0),
holiday_hours=Decimal(str(row.get("holiday_hours", 0))) if row.get("holiday_hours") else Decimal(0),
late_hours=Decimal(str(row.get("late_hours", 0))) if row.get("late_hours") else Decimal(0),
absent_days=Decimal(str(row.get("absent_days", 0))) if row.get("absent_days") else Decimal(0),
# 支給項目
base_salary=Decimal(str(row.get("base_salary", 0))) if row.get("base_salary") else Decimal(0),
overtime_pay=Decimal(str(row.get("overtime_pay", 0))) if row.get("overtime_pay") else Decimal(0),
holiday_pay=Decimal(str(row.get("holiday_pay", 0))) if row.get("holiday_pay") else Decimal(0),
commute_allowance=Decimal(str(row.get("commute_allowance", 0))) if row.get("commute_allowance") else Decimal(0),
other_allowance=Decimal(str(row.get("other_allowance", 0))) if row.get("other_allowance") else Decimal(0),
total_payment=Decimal(str(row.get("total_payment", 0))) if row.get("total_payment") else Decimal(0),
# 控除項目
health_insurance=Decimal(str(row.get("health_insurance", 0))) if row.get("health_insurance") else Decimal(0),
care_insurance=Decimal(str(row.get("care_insurance", 0))) if row.get("care_insurance") else Decimal(0),
pension_insurance=Decimal(str(row.get("pension_insurance", 0))) if row.get("pension_insurance") else Decimal(0),
employment_insurance=Decimal(str(row.get("employment_insurance", 0))) if row.get("employment_insurance") else Decimal(0),
income_tax=Decimal(str(row.get("income_tax", 0))) if row.get("income_tax") else Decimal(0),
resident_tax=Decimal(str(row.get("resident_tax", 0))) if row.get("resident_tax") else Decimal(0),
other_deduction=Decimal(str(row.get("other_deduction", 0))) if row.get("other_deduction") else Decimal(0),
total_deduction=Decimal(str(row.get("total_deduction", 0))) if row.get("total_deduction") else Decimal(0),
net_payment=Decimal(str(row.get("net_payment", 0))) if row.get("net_payment") else Decimal(0),
# レガシー互換性用
gross_salary=Decimal(str(row.get("base_salary", 0))) if row.get("base_salary") else Decimal(0),
total_deductions=Decimal(str(row.get("total_deduction", 0))) if row.get("total_deduction") else Decimal(0),
net_salary=Decimal(str(row.get("net_payment", 0))) if row.get("net_payment") else Decimal(0)
)
data.append(salary_obj)
else:
# Tuple format (indexes 0-33 based on SELECT order)
salary_obj = SalaryVoucherData(
employee_id=row[0],
employee_code=row[1],
employee_name=row[2],
payroll_year=row[3],
payroll_month=row[4],
# 勤怠情報
working_days=Decimal(str(row[5])) if row[5] else Decimal(0),
working_hours=Decimal(str(row[6])) if row[6] else Decimal(0),
overtime_hours=Decimal(str(row[7])) if row[7] else Decimal(0),
holiday_hours=Decimal(str(row[8])) if row[8] else Decimal(0),
late_hours=Decimal(str(row[9])) if row[9] else Decimal(0),
absent_days=Decimal(str(row[10])) if row[10] else Decimal(0),
# 支給項目
base_salary=Decimal(str(row[11])) if row[11] else Decimal(0),
overtime_pay=Decimal(str(row[12])) if row[12] else Decimal(0),
holiday_pay=Decimal(str(row[13])) if row[13] else Decimal(0),
commute_allowance=Decimal(str(row[14])) if row[14] else Decimal(0),
other_allowance=Decimal(str(row[15])) if row[15] else Decimal(0),
total_payment=Decimal(str(row[16])) if row[16] else Decimal(0),
# 控除項目
health_insurance=Decimal(str(row[17])) if row[17] else Decimal(0),
care_insurance=Decimal(str(row[18])) if row[18] else Decimal(0),
pension_insurance=Decimal(str(row[19])) if row[19] else Decimal(0),
employment_insurance=Decimal(str(row[20])) if row[20] else Decimal(0),
income_tax=Decimal(str(row[21])) if row[21] else Decimal(0),
resident_tax=Decimal(str(row[22])) if row[22] else Decimal(0),
other_deduction=Decimal(str(row[23])) if row[23] else Decimal(0),
total_deduction=Decimal(str(row[24])) if row[24] else Decimal(0),
net_payment=Decimal(str(row[25])) if row[25] else Decimal(0),
# レガシー互換性用
gross_salary=Decimal(str(row[11])) if row[11] else Decimal(0),
total_deductions=Decimal(str(row[24])) if row[24] else Decimal(0),
net_salary=Decimal(str(row[25])) if row[25] else Decimal(0)
)
data.append(salary_obj)
return data
except Exception as e:
logger.error(f"給与データ取得エラー (employee_id={employee_id}): {str(e)}")
return []
def get_bonus_data_for_period(
employee_id: int,
start_year: int, start_month: int,
end_year: int, end_month: int
) -> List[BonusVoucherData]:
"""期間の賞与データを取得"""
try:
with get_connection() as conn:
with conn.cursor() as cur:
start_ym = start_year * 100 + start_month
end_ym = end_year * 100 + end_month
cur.execute("""
SELECT
e.employee_id, e.employee_code, e.name,
bp.bonus_year, bp.bonus_month, bp.bonus_type,
bp.base_bonus, bp.performance_bonus, bp.other_bonus, bp.total_bonus,
bp.health_insurance, bp.care_insurance, bp.pension_insurance, bp.employment_insurance,
bp.income_tax, bp.other_deduction, bp.total_deduction,
bp.net_bonus
FROM employees e
JOIN bonus_payments bp ON e.employee_id = bp.employee_id
WHERE e.employee_id = %s
AND (bp.bonus_year * 100 + bp.bonus_month) >= %s
AND (bp.bonus_year * 100 + bp.bonus_month) <= %s
ORDER BY bp.bonus_year, bp.bonus_month
""", (employee_id, start_ym, end_ym))
rows = cur.fetchall()
data = []
for row in rows:
# Handle both dict and tuple formats
if isinstance(row, dict):
bonus_obj = BonusVoucherData(
employee_id=row.get("employee_id"),
employee_code=row.get("employee_code"),
employee_name=row.get("name"),
bonus_year=row.get("bonus_year"),
bonus_month=row.get("bonus_month"),
bonus_type=row.get("bonus_type", ""),
# 支給項目
base_bonus=Decimal(str(row.get("base_bonus", 0))) if row.get("base_bonus") else Decimal(0),
performance_bonus=Decimal(str(row.get("performance_bonus", 0))) if row.get("performance_bonus") else Decimal(0),
other_bonus=Decimal(str(row.get("other_bonus", 0))) if row.get("other_bonus") else Decimal(0),
total_bonus=Decimal(str(row.get("total_bonus", 0))) if row.get("total_bonus") else Decimal(0),
# 控除項目
health_insurance=Decimal(str(row.get("health_insurance", 0))) if row.get("health_insurance") else Decimal(0),
care_insurance=Decimal(str(row.get("care_insurance", 0))) if row.get("care_insurance") else Decimal(0),
pension_insurance=Decimal(str(row.get("pension_insurance", 0))) if row.get("pension_insurance") else Decimal(0),
employment_insurance=Decimal(str(row.get("employment_insurance", 0))) if row.get("employment_insurance") else Decimal(0),
income_tax=Decimal(str(row.get("income_tax", 0))) if row.get("income_tax") else Decimal(0),
other_deduction=Decimal(str(row.get("other_deduction", 0))) if row.get("other_deduction") else Decimal(0),
total_deduction=Decimal(str(row.get("total_deduction", 0))) if row.get("total_deduction") else Decimal(0),
net_bonus=Decimal(str(row.get("net_bonus", 0))) if row.get("net_bonus") else Decimal(0),
# レガシー互換性用
bonus_amount=Decimal(str(row.get("total_bonus", 0))) if row.get("total_bonus") else Decimal(0),
tax_amount=Decimal(str(row.get("total_deduction", 0))) if row.get("total_deduction") else Decimal(0)
)
data.append(bonus_obj)
else:
# Tuple format (indexes 0-17 based on SELECT order)
bonus_obj = BonusVoucherData(
employee_id=row[0],
employee_code=row[1],
employee_name=row[2],
bonus_year=row[3],
bonus_month=row[4],
bonus_type=row[5] if row[5] else "",
# 支給項目
base_bonus=Decimal(str(row[6])) if row[6] else Decimal(0),
performance_bonus=Decimal(str(row[7])) if row[7] else Decimal(0),
other_bonus=Decimal(str(row[8])) if row[8] else Decimal(0),
total_bonus=Decimal(str(row[9])) if row[9] else Decimal(0),
# 控除項目
health_insurance=Decimal(str(row[10])) if row[10] else Decimal(0),
care_insurance=Decimal(str(row[11])) if row[11] else Decimal(0),
pension_insurance=Decimal(str(row[12])) if row[12] else Decimal(0),
employment_insurance=Decimal(str(row[13])) if row[13] else Decimal(0),
income_tax=Decimal(str(row[14])) if row[14] else Decimal(0),
other_deduction=Decimal(str(row[15])) if row[15] else Decimal(0),
total_deduction=Decimal(str(row[16])) if row[16] else Decimal(0),
net_bonus=Decimal(str(row[17])) if row[17] else Decimal(0),
# レガシー互換性用
bonus_amount=Decimal(str(row[9])) if row[9] else Decimal(0),
tax_amount=Decimal(str(row[16])) if row[16] else Decimal(0)
)
data.append(bonus_obj)
return data
except Exception as e:
logger.error(f"賞与データ取得エラー (employee_id={employee_id}): {str(e)}")
return []
def build_voucher_summary(
employee_ids: List[int],
start_year: int, start_month: int,
end_year: int, end_month: int,
salary_data: Dict[int, List], bonus_data: Dict[int, List]
) -> List[VoucherDataSummary]:
"""従業員ごとの個票摘要を構築"""
summaries = []
for emp_id in employee_ids:
salary_list = salary_data.get(emp_id, [])
bonus_list = bonus_data.get(emp_id, [])
# 従業員情報取得
try:
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT employee_code, name FROM employees WHERE employee_id = %s",
(emp_id,)
)
emp_row = cur.fetchone()
if not emp_row:
continue
# Handle both dict and tuple formats
if isinstance(emp_row, dict):
emp_code = emp_row.get("employee_code")
emp_name = emp_row.get("name")
else:
emp_code, emp_name = emp_row
except Exception as e:
logger.error(f"従業員情報取得エラー: {str(e)}")
continue
# 月のリスト構築
salary_months = []
if salary_list:
salary_months = [
f"{s.payroll_year:04d}-{s.payroll_month:02d}"
for s in salary_list
]
bonus_months = []
if bonus_list:
bonus_months = [
f"{b.bonus_year:04d}-{b.bonus_month:02d}"
for b in bonus_list
]
summary = VoucherDataSummary(
employee_id=emp_id,
employee_code=emp_code,
employee_name=emp_name,
has_salary_data=len(salary_list) > 0,
has_bonus_data=len(bonus_list) > 0,
salary_count=len(salary_list),
bonus_count=len(bonus_list),
salary_months=salary_months,
bonus_months=bonus_months
)
summaries.append(summary)
return summaries
def export_vouchers(request: VoucherExportRequest) -> VoucherExportResponse:
"""账票エクスポート処理(メイン)"""
try:
# 日付範囲検証
start_year, start_month, end_year, end_month = validate_date_range(
request.start_year, request.start_month,
request.end_year, request.end_month
)
# 給与・賞与データ取得
salary_data = {}
bonus_data = {}
if request.voucher_type in ["all", "salary"]:
for emp_id in request.employee_ids:
data = get_salary_data_for_period(
emp_id, start_year, start_month, end_year, end_month
)
salary_data[emp_id] = data
if request.voucher_type in ["all", "bonus"]:
for emp_id in request.employee_ids:
data = get_bonus_data_for_period(
emp_id, start_year, start_month, end_year, end_month
)
bonus_data[emp_id] = data
# データの存在判定
has_any_data = any(
salary_data.get(emp_id, []) or bonus_data.get(emp_id, [])
for emp_id in request.employee_ids
)
if not has_any_data:
return VoucherExportResponse(
status="no_data",
has_data=False,
message="選定條件下、沒有找到任何數據。請檢查日期範圍和従業員選擇。"
)
# 摘要構築
summary = build_voucher_summary(
request.employee_ids,
start_year, start_month, end_year, end_month,
salary_data, bonus_data
)
# レスポンス構築
data_dict = {}
if request.voucher_type in ["all", "salary"]:
data_dict["salary"] = [
s.dict() for emp_data in salary_data.values()
for s in emp_data
]
if request.voucher_type in ["all", "bonus"]:
data_dict["bonus"] = [
b.dict() for emp_data in bonus_data.values()
for b in emp_data
]
# メッセージ構築
emp_with_data = len([s for s in summary if s.has_salary_data or s.has_bonus_data])
message = f"✓ 成功查詢: {emp_with_data}位従業員有數據"
return VoucherExportResponse(
status="success",
has_data=True,
message=message,
summary=summary,
data=data_dict
)
except ValueError as e:
logger.error(f"バリデーションエラー: {str(e)}")
raise
except Exception as e:
logger.error(f"エクスポートエラー: {str(e)}")
raise