Files
njts-accounting-core/frontend/withholding-slip.html
admin 63ac6bc99a 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: ルーター登録
2026-05-20 17:10:08 +09:00

587 lines
23 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>