This commit is contained in:
FNOS-WIN11ENT\choshoukaku
2025-12-15 17:57:49 +09:00
parent b6751ced18
commit 88163423e0
5 changed files with 603 additions and 470 deletions

View File

@@ -1,54 +1,41 @@
<!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>
<meta charset="UTF-8" />
<title>仕訳入力</title>
<style>
body { font-family: sans-serif; margin: 24px; }
h2 { margin: 0 0 12px; }
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; }
.row { margin: 8px 0; display: flex; gap: 16px; align-items: center; }
.row label { min-width: 120px; }
.tax-row { background: #f0f8ff; }
</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>
<h2>仕訳入力</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>
<label>仕訳日</label>
<input type="date" id="entryDate" />
</div>
<div class="row">
<div>
<label>仮払消費税等 勘定</label>
<select id="taxDebitAccount"></select>
</div>
<div>
<label>消費税等 勘定</label>
<select id="taxCreditAccount"></select>
</div>
<label>摘要</label>
<input type="text" id="description" style="flex:1" placeholder="例:売上入金/経費支払 など" />
</div>
<div class="row">
<label>消費税等 勘定</label>
<select id="taxPaidSelect"></select>
<label>仮受消費税等 勘定</label>
<select id="taxReceivedSelect"></select>
</div>
<table id="linesTable">
@@ -63,261 +50,210 @@
</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>
<div style="margin-top:10px; display:flex; gap:8px;">
<button onclick="addRow()"> 行追加</button>
<button onclick="submitJournal()">登録</button>
</div>
<script>
const API_BASE = "http://127.0.0.1:18080";
const API = "http://127.0.0.1:18080";
let accounts = [];
let taxPaidAccounts = [];
let taxReceivedAccounts = [];
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`);
const res = await fetch(`${API}/accounts`);
if (!res.ok) {
alert("科目一覧の取得に失敗しました");
return;
}
accounts = await res.json();
// 税勘定候補を抽出
const taxDebitSel = document.getElementById("taxDebitAccount");
const taxCreditSel = document.getElementById("taxCreditAccount");
// 消費税用勘定を抽出(名前は「仮払」「仮受」を含む想定)
// 仮払消費税等:资产 + code 504或你实际的 code
taxPaidAccounts = accounts.filter(
a => a.account_code === "504"
);
const taxDebitCandidates = accounts.filter(a => a.account_name.includes("仮払消費税"));
const taxCreditCandidates = accounts.filter(a => a.account_name.includes("仮受消費税"));
// 仮受消費税等:负债 + code 505或你实际的 code
taxReceivedAccounts = accounts.filter(
a => a.account_code === "505"
);
// 仮払
fillSelect(taxDebitSel, taxDebitCandidates.length ? taxDebitCandidates : accounts);
// 仮受
fillSelect(taxCreditSel, taxCreditCandidates.length ? taxCreditCandidates : accounts);
setupTaxSelectors();
addRow();
addRow();
recalc();
// 仕訳日:既定で今日
document.getElementById("entryDate").value = new Date().toISOString().slice(0,10);
}
init();
// -----------------------------
// 仮払/仮受 セレクタ
// -----------------------------
function setupTaxSelectors() {
document.getElementById("taxPaidSelect").innerHTML =
`<option value="">-- 選択してください --</option>` +
taxPaidAccounts.map(a =>
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
).join("");
document.getElementById("taxReceivedSelect").innerHTML =
`<option value="">-- 選択してください --</option>` +
taxReceivedAccounts.map(a =>
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
).join("");
}
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() {
// -----------------------------
// 行追加
// -----------------------------
function addRow(isTaxRow = false, taxInfo = null) {
const tbody = document.querySelector("#linesTable tbody");
const tr = document.createElement("tr");
if (isTaxRow) tr.classList.add("tax-row");
tr.innerHTML = `
<td>
<select class="account">
<select class="account" ${isTaxRow ? "disabled" : ""}>
${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><input type="number" class="debit right" min="0" ${isTaxRow ? "readonly" : ""}></td>
<td><input type="number" class="credit right" min="0" ${isTaxRow ? "readonly" : ""}></td>
<td>
<select class="tax">
${TAX_OPTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
<select class="taxType" ${isTaxRow ? "disabled" : ""}>
<option value="none">対象外</option>
<option value="10">10%</option>
<option value="8">8%</option>
</select>
</td>
<td>
<select class="taxdir">
${TAX_DIRECTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
<select class="taxDirection" ${isTaxRow ? "disabled" : ""}>
<option value="none">対象外</option>
<option value="paid">仮払(仕入・経費)</option>
<option value="received">仮受(売上)</option>
</select>
</td>
<td><button onclick="removeRow(this)">削除</button></td>
<td>${isTaxRow ? "" : `<button onclick="removeRow(this)">削除</button>`}</td>
`;
tbody.appendChild(tr);
if (!isTaxRow) {
tr.querySelector(".debit").addEventListener("input", () => handleTax(tr));
tr.querySelector(".credit").addEventListener("input", () => handleTax(tr));
tr.querySelector(".taxType").addEventListener("change", () => handleTax(tr));
tr.querySelector(".taxDirection").addEventListener("change", () => handleTax(tr));
}
if (isTaxRow && taxInfo) {
const acc = tr.querySelector(".account");
acc.value = taxInfo.accountId || "";
if (taxInfo.side === "debit") tr.querySelector(".debit").value = taxInfo.amount;
if (taxInfo.side === "credit") tr.querySelector(".credit").value = taxInfo.amount;
}
}
// -----------------------------
// 税行 自動生成/更新10%/8%のみ、四捨五入)
// -----------------------------
function handleTax(row) {
removeTaxRow(row);
const taxType = row.querySelector(".taxType").value;
const taxDir = row.querySelector(".taxDirection").value;
const debit = Number(row.querySelector(".debit").value || 0);
const credit = Number(row.querySelector(".credit").value || 0);
if (taxType === "none" || taxDir === "none") return;
const baseAmount = debit || credit;
if (!baseAmount) return;
const rate = taxType === "8" ? 0.08 : 0.10;
const taxAmount = Math.round(baseAmount * rate);
if (taxDir === "paid") {
const taxAcc = document.getElementById("taxPaidSelect").value;
if (!taxAcc) { alert("仮払消費税等 勘定を選択してください"); return; }
addRow(true, { accountId: taxAcc, amount: taxAmount, side: "debit" });
}
if (taxDir === "received") {
const taxAcc = document.getElementById("taxReceivedSelect").value;
if (!taxAcc) { alert("仮受消費税等 勘定を選択してください"); return; }
addRow(true, { accountId: taxAcc, amount: taxAmount, side: "credit" });
}
}
// 税行は常に「直後の1行」を使う
function removeTaxRow(row) {
const next = row.nextElementSibling;
if (next && next.classList.contains("tax-row")) next.remove();
}
function removeRow(btn) {
btn.closest("tr").remove();
recalc();
const row = btn.closest("tr");
removeTaxRow(row);
row.remove();
}
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 = "";
// -----------------------------
// 登録(最小可用版)
// -----------------------------
async function submitJournal() {
const entryDate = document.getElementById("entryDate").value;
const desc = document.getElementById("description").value;
if (!entryDate) return showError("仕訳日を入力してください");
if (!desc) return showError("摘要を入力してください");
const description = document.getElementById("description").value.trim();
const taxDebitAccountId = Number(document.getElementById("taxDebitAccount").value);
const taxCreditAccountId = Number(document.getElementById("taxCreditAccount").value);
if (!entryDate) { alert("仕訳日を入力してください"); return; }
const baseLines = [];
const taxLines = [];
// 明細収集
const rows = document.querySelectorAll("#linesTable tbody tr");
const lines = [];
let totalDebit = 0, totalCredit = 0;
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;
rows.forEach(row => {
const accountId = Number(row.querySelector(".account").value);
const debit = Number(row.querySelector(".debit").value || 0);
const credit = Number(row.querySelector(".credit").value || 0);
// 空行は無視
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 });
}
}
}
lines.push({ account_id: accountId, debit: debit, credit: credit });
totalDebit += debit;
totalCredit += credit;
});
const lines = [...baseLines, ...taxLines];
if (lines.length === 0) { alert("仕訳明細がありません"); return; }
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("通信エラー");
// 借貸一致チェック(最小)
if (totalDebit !== totalCredit) {
alert(`借方(${totalDebit.toLocaleString()})と貸方(${totalCredit.toLocaleString()})が一致しません`);
return;
}
}
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";
}
const payload = { entry_date: entryDate, description, lines };
// 合計の初期表示用
document.addEventListener("input", () => recalc());
init();
const res = await fetch(`${API}/journal-entries`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload)
});
if (!res.ok) { alert("登録に失敗しました"); return; }
const result = await res.json();
alert(`登録完了ID=${result.journal_entry_id}`);
location.reload();
}
</script>
</body>