325 lines
9.4 KiB
HTML
325 lines
9.4 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ja">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<title>仕訳入力(複数行・消費税自動)</title>
|
||
<style>
|
||
body { font-family: sans-serif; margin: 24px; }
|
||
table { border-collapse: collapse; width: 100%; margin-top: 12px; }
|
||
th, td { border: 1px solid #ccc; padding: 6px; text-align: center; }
|
||
th { background: #f5f5f5; }
|
||
input, select, button { padding: 6px; }
|
||
.right { text-align: right; }
|
||
.error { color: #c00; margin-top: 8px; }
|
||
.ok { color: #090; margin-top: 8px; }
|
||
.row { display: flex; gap: 12px; align-items: center; margin: 8px 0; }
|
||
.row > * { flex: 1; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<h2><div class="row">
|
||
<div>
|
||
<label>消費税 端数処理</label>
|
||
<select id="roundingMode">
|
||
<option value="round">四捨五入</option>
|
||
<option value="floor">切捨て</option>
|
||
<option value="ceil">切上げ</option>
|
||
</select>
|
||
</div>
|
||
</div></h2>
|
||
|
||
<div class="row">
|
||
<div>
|
||
<label>仕訳日</label>
|
||
<input type="date" id="entryDate" />
|
||
</div>
|
||
<div style="flex:2">
|
||
<label>摘要</label>
|
||
<input type="text" id="description" placeholder="例:売上計上/経費支払" style="width:100%" />
|
||
</div>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<div>
|
||
<label>仮払消費税等 勘定</label>
|
||
<select id="taxDebitAccount"></select>
|
||
</div>
|
||
<div>
|
||
<label>仮受消費税等 勘定</label>
|
||
<select id="taxCreditAccount"></select>
|
||
</div>
|
||
</div>
|
||
|
||
<table id="linesTable">
|
||
<thead>
|
||
<tr>
|
||
<th>科目</th>
|
||
<th>借方</th>
|
||
<th>貸方</th>
|
||
<th>税区分</th>
|
||
<th>税方向</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
<tfoot>
|
||
<tr>
|
||
<th>合計</th>
|
||
<th id="debitTotal" class="right">0</th>
|
||
<th id="creditTotal" class="right">0</th>
|
||
<th colspan="3"></th>
|
||
</tr>
|
||
</tfoot>
|
||
</table>
|
||
|
||
<button onclick="addRow()">+ 行追加</button>
|
||
<button onclick="submitEntry()">登録</button>
|
||
|
||
<div id="result"></div>
|
||
|
||
<script>
|
||
const API_BASE = "http://127.0.0.1:18080";
|
||
let accounts = [];
|
||
|
||
const TAX_OPTIONS = [
|
||
{ value: "none", label: "非課税" },
|
||
{ value: "10-excl", label: "10%(税抜)" },
|
||
{ value: "10-incl", label: "10%(税込)" },
|
||
{ value: "8-excl", label: "8%(税抜)" },
|
||
{ value: "8-incl", label: "8%(税込)" }
|
||
];
|
||
|
||
const TAX_DIRECTIONS = [
|
||
{ value: "karibarai", label: "仮払(仕入・経費側)" },
|
||
{ value: "kariuke", label: "仮受(売上側)" }
|
||
];
|
||
|
||
// ------------------------
|
||
// 初期化
|
||
// ------------------------
|
||
async function init() {
|
||
const res = await fetch(`${API_BASE}/accounts`);
|
||
accounts = await res.json();
|
||
|
||
// 税勘定候補を抽出
|
||
const taxDebitSel = document.getElementById("taxDebitAccount");
|
||
const taxCreditSel = document.getElementById("taxCreditAccount");
|
||
|
||
const taxDebitCandidates = accounts.filter(a => a.account_name.includes("仮払消費税"));
|
||
const taxCreditCandidates = accounts.filter(a => a.account_name.includes("仮受消費税"));
|
||
|
||
// 仮払
|
||
fillSelect(taxDebitSel, taxDebitCandidates.length ? taxDebitCandidates : accounts);
|
||
// 仮受
|
||
fillSelect(taxCreditSel, taxCreditCandidates.length ? taxCreditCandidates : accounts);
|
||
|
||
addRow();
|
||
addRow();
|
||
recalc();
|
||
}
|
||
|
||
function fillSelect(sel, list) {
|
||
sel.innerHTML = "";
|
||
list.forEach(a => {
|
||
const opt = document.createElement("option");
|
||
opt.value = a.account_id;
|
||
opt.textContent = `${a.account_code} ${a.account_name}`;
|
||
sel.appendChild(opt);
|
||
});
|
||
}
|
||
|
||
// ------------------------
|
||
// 行追加・削除
|
||
// ------------------------
|
||
function addRow() {
|
||
const tbody = document.querySelector("#linesTable tbody");
|
||
const tr = document.createElement("tr");
|
||
|
||
tr.innerHTML = `
|
||
<td>
|
||
<select class="account">
|
||
${accounts.map(a =>
|
||
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
|
||
).join("")}
|
||
</select>
|
||
</td>
|
||
<td><input type="number" min="0" class="debit" oninput="onDebitInput(this)" /></td>
|
||
<td><input type="number" min="0" class="credit" oninput="onCreditInput(this)" /></td>
|
||
<td>
|
||
<select class="tax">
|
||
${TAX_OPTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<select class="taxdir">
|
||
${TAX_DIRECTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
|
||
</select>
|
||
</td>
|
||
<td><button onclick="removeRow(this)">削除</button></td>
|
||
`;
|
||
tbody.appendChild(tr);
|
||
}
|
||
|
||
function removeRow(btn) {
|
||
btn.closest("tr").remove();
|
||
recalc();
|
||
}
|
||
|
||
function onDebitInput(input) {
|
||
const credit = input.closest("tr").querySelector(".credit");
|
||
if (input.value) credit.value = "";
|
||
recalc();
|
||
}
|
||
|
||
function onCreditInput(input) {
|
||
const debit = input.closest("tr").querySelector(".debit");
|
||
if (input.value) debit.value = "";
|
||
recalc();
|
||
}
|
||
|
||
// ------------------------
|
||
// 合計再計算(税は送信時に展開するので、ここは本体行のみ)
|
||
// ------------------------
|
||
function recalc() {
|
||
let debitTotal = 0;
|
||
let creditTotal = 0;
|
||
document.querySelectorAll("#linesTable tbody tr").forEach(tr => {
|
||
const d = Number(tr.querySelector(".debit").value || 0);
|
||
const c = Number(tr.querySelector(".credit").value || 0);
|
||
debitTotal += d;
|
||
creditTotal += c;
|
||
});
|
||
document.getElementById("debitTotal").textContent = debitTotal.toLocaleString();
|
||
document.getElementById("creditTotal").textContent = creditTotal.toLocaleString();
|
||
}
|
||
|
||
// ------------------------
|
||
// 税計算(四捨五入)
|
||
// ------------------------
|
||
function calcTaxSplit(amount, rate, inclusive) {
|
||
if (!rate) return { base: amount, tax: 0 };
|
||
|
||
if (!inclusive) {
|
||
// 税抜:amount = 本体
|
||
const tax = applyRounding(amount * rate);
|
||
return { base: amount, tax };
|
||
} else {
|
||
// 税込:amount = 総額
|
||
const base = applyRounding(amount / (1 + rate));
|
||
const tax = amount - base; // 差額は必ず一致させる
|
||
return { base, tax };
|
||
}
|
||
}
|
||
|
||
function parseTaxOption(opt) {
|
||
if (opt === "none") return { rate: 0, inclusive: false };
|
||
const [rateStr, mode] = opt.split("-");
|
||
const rate = rateStr === "10" ? 0.10 : 0.08;
|
||
return { rate, inclusive: mode === "incl" };
|
||
}
|
||
|
||
// ------------------------
|
||
// 送信(税行を自動付与)
|
||
// ------------------------
|
||
async function submitEntry() {
|
||
const result = document.getElementById("result");
|
||
result.textContent = "";
|
||
result.className = "";
|
||
|
||
const entryDate = document.getElementById("entryDate").value;
|
||
const desc = document.getElementById("description").value;
|
||
if (!entryDate) return showError("仕訳日を入力してください");
|
||
if (!desc) return showError("摘要を入力してください");
|
||
|
||
const taxDebitAccountId = Number(document.getElementById("taxDebitAccount").value);
|
||
const taxCreditAccountId = Number(document.getElementById("taxCreditAccount").value);
|
||
|
||
const baseLines = [];
|
||
const taxLines = [];
|
||
|
||
document.querySelectorAll("#linesTable tbody tr").forEach(tr => {
|
||
const accountId = Number(tr.querySelector(".account").value);
|
||
const debit = Number(tr.querySelector(".debit").value || 0);
|
||
const credit = Number(tr.querySelector(".credit").value || 0);
|
||
const taxOpt = tr.querySelector(".tax").value;
|
||
const taxDir = tr.querySelector(".taxdir").value;
|
||
|
||
// 空行は無視
|
||
if (debit === 0 && credit === 0) return;
|
||
|
||
const { rate, inclusive } = parseTaxOption(taxOpt);
|
||
|
||
if (debit > 0) {
|
||
const { base, tax } = calcTaxSplit(debit, rate, inclusive);
|
||
baseLines.push({ account_id: accountId, debit: base, credit: 0 });
|
||
if (tax > 0) {
|
||
if (taxDir === "karibarai") {
|
||
taxLines.push({ account_id: taxDebitAccountId, debit: tax, credit: 0 });
|
||
} else {
|
||
taxLines.push({ account_id: taxCreditAccountId, debit: 0, credit: tax });
|
||
}
|
||
}
|
||
} else {
|
||
const { base, tax } = calcTaxSplit(credit, rate, inclusive);
|
||
baseLines.push({ account_id: accountId, debit: 0, credit: base });
|
||
if (tax > 0) {
|
||
if (taxDir === "karibarai") {
|
||
taxLines.push({ account_id: taxDebitAccountId, debit: tax, credit: 0 });
|
||
} else {
|
||
taxLines.push({ account_id: taxCreditAccountId, debit: 0, credit: tax });
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
const lines = [...baseLines, ...taxLines];
|
||
|
||
if (lines.length < 2)
|
||
return showError("仕訳行は2行以上必要です");
|
||
|
||
// 借貸チェック(送信前に最終確認)
|
||
const tot = lines.reduce((s, l) => {
|
||
s.debit += Number(l.debit || 0);
|
||
s.credit += Number(l.credit || 0);
|
||
return s;
|
||
}, {debit:0, credit:0});
|
||
|
||
if (tot.debit !== tot.credit)
|
||
return showError(`借方合計(${tot.debit})と貸方合計(${tot.credit})が一致していません`);
|
||
|
||
const payload = { entry_date: entryDate, description: desc, lines };
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/journal-entries`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload)
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) return showError(data.detail || "登録失敗");
|
||
showOk(`登録完了(ID: ${data.journal_entry_id})`);
|
||
} catch {
|
||
showError("通信エラー");
|
||
}
|
||
}
|
||
|
||
function showError(msg) {
|
||
const r = document.getElementById("result");
|
||
r.textContent = msg;
|
||
r.className = "error";
|
||
}
|
||
function showOk(msg) {
|
||
const r = document.getElementById("result");
|
||
r.textContent = msg;
|
||
r.className = "ok";
|
||
}
|
||
|
||
// 合計の初期表示用
|
||
document.addEventListener("input", () => recalc());
|
||
init();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|