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:
admin
2026-05-20 17:10:08 +09:00
parent a071a11df3
commit 63ac6bc99a
7 changed files with 1159 additions and 0 deletions

View File

@@ -110,6 +110,15 @@
<h2>税率・保険料設定</h2>
<p>社会保険料率、所得税率表の管理を行います</p>
</div>
<div
class="menu-card"
onclick="location.href = 'withholding-slip.html'"
>
<div class="icon">📄</div>
<h2>源泉徴収票</h2>
<p>給与所得の源泉徴収票を発行します(本人用・区役所用・税務署用 各1部</p>
</div>
</div>
<div
@@ -135,6 +144,10 @@
<strong>月次給与計算</strong
>で勤怠情報を入力し、給与を計算・承認します
</li>
<li>
<strong>源泉徴収票</strong
>で年末に給与所得の源泉徴収票本人・区役所・税務署の3部を発行します
</li>
</ol>
</div>
</div>

View 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function maskMyNumber(mn) {
if (!mn) return ' ';
// 税務署提出用は個人番号を表示(本来は管理が必要)
return mn;
}
function printSlips() {
window.print();
}
// 初期化
initYearSelect();
loadEmployees();
</script>
</body>
</html>