482 lines
13 KiB
HTML
482 lines
13 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ja">
|
||
<head>
|
||
<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>仕訳入力</h2>
|
||
|
||
<div class="row">
|
||
<label>仕訳日</label>
|
||
<input type="date" id="entryDate" onchange="onEntryDateChanged()"/>
|
||
</div>
|
||
|
||
<div class="row">
|
||
<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>
|
||
<div style="margin-bottom: 10px;">
|
||
<label>消費税 端数処理:</label>
|
||
<select id="roundingMode">
|
||
<option value="round">四捨五入</option>
|
||
<option value="floor">切捨て</option>
|
||
<option value="ceil">切上げ</option>
|
||
</select>
|
||
</div>
|
||
|
||
<table id="linesTable">
|
||
<thead>
|
||
<tr>
|
||
<th>科目</th>
|
||
<th>借方</th>
|
||
<th>貸方</th>
|
||
<th>税区分</th>
|
||
<th>税方向</th>
|
||
<th>操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody></tbody>
|
||
</table>
|
||
|
||
<div style="margin-top:10px; display:flex; gap:8px;">
|
||
<button onclick="addRow()">+ 行追加</button>
|
||
<button onclick="submitJournal()">登録</button>
|
||
</div>
|
||
|
||
<h3>月次锁定管理</h3>
|
||
|
||
<div style="margin-bottom: 8px;">
|
||
<label>年度:</label>
|
||
<input type="number" id="lockYear" value="2025" style="width:80px;">
|
||
|
||
<label>月份:</label>
|
||
<input type="number" id="lockMonth" min="1" max="12" value="6" style="width:60px;">
|
||
</div>
|
||
|
||
<button onclick="lockMonth()">🔒 锁定</button>
|
||
<button onclick="unlockMonth()">🔓 解锁</button>
|
||
|
||
|
||
|
||
|
||
<div id="lockResult" style="margin-top:10px;color:#333;"></div>
|
||
|
||
<div style="margin-top:12px;">
|
||
<button onclick="refreshMonthLocks()">🔄 刷新锁定列表</button>
|
||
<pre id="lockList" style="background:#f7f7f7;padding:8px;"></pre>
|
||
</div>
|
||
|
||
<div id="monthLockBanner"
|
||
style="display:none;
|
||
margin:8px 0;
|
||
padding:6px 10px;
|
||
background:#ffecec;
|
||
border:1px solid #ffaaaa;
|
||
color:#a00000;">
|
||
🔒 该月份已锁定,不能编辑仕訳
|
||
</div>
|
||
|
||
<script>
|
||
|
||
let rowSeq = 0; // 各行唯一ID
|
||
|
||
const API = "http://127.0.0.1:18080";
|
||
|
||
let accounts = [];
|
||
let taxPaidAccounts = [];
|
||
let taxReceivedAccounts = [];
|
||
|
||
// -----------------------------
|
||
// 初期化
|
||
// -----------------------------
|
||
async function init() {
|
||
const res = await fetch(`${API}/accounts`);
|
||
if (!res.ok) {
|
||
alert("科目一覧の取得に失敗しました");
|
||
return;
|
||
}
|
||
accounts = await res.json();
|
||
|
||
// 消費税用勘定を抽出(名前は「仮払」「仮受」を含む想定)
|
||
// 仮払消費税等:资产 + code 504(或你实际的 code)
|
||
taxPaidAccounts = accounts.filter(
|
||
a => a.account_code === "504"
|
||
);
|
||
|
||
// 仮受消費税等:负债 + code 505(或你实际的 code)
|
||
taxReceivedAccounts = accounts.filter(
|
||
a => a.account_code === "505"
|
||
);
|
||
|
||
|
||
setupTaxSelectors();
|
||
addRow();
|
||
|
||
// 仕訳日:既定で今日
|
||
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 addRow(isTaxRow = false, taxInfo = null, parentId = null) {
|
||
const tbody = document.querySelector("#linesTable tbody");
|
||
const tr = document.createElement("tr");
|
||
|
||
if (!isTaxRow) {
|
||
tr.dataset.rowId = String(++rowSeq);
|
||
} else {
|
||
tr.classList.add("tax-row");
|
||
tr.dataset.parentId = parentId;
|
||
}
|
||
|
||
tr.innerHTML = `
|
||
<td>
|
||
<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" class="debit right" min="0" ${isTaxRow ? "readonly" : ""}></td>
|
||
<td><input type="number" class="credit right" min="0" ${isTaxRow ? "readonly" : ""}></td>
|
||
<td>
|
||
<select class="taxType" ${isTaxRow ? "disabled" : ""}>
|
||
<option value="none">対象外</option>
|
||
<option value="10">10%</option>
|
||
<option value="8">8%</option>
|
||
</select>
|
||
</td>
|
||
<td>
|
||
<select class="taxDirection" ${isTaxRow ? "disabled" : ""}>
|
||
<option value="none">対象外</option>
|
||
<option value="paid">仮払(仕入・経費)</option>
|
||
<option value="received">仮受(売上)</option>
|
||
</select>
|
||
</td>
|
||
<td>${isTaxRow ? "" : `<button onclick="removeRow(this)">削除</button>`}</td>
|
||
`;
|
||
|
||
tbody.appendChild(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;
|
||
}
|
||
|
||
// 普通行:绑定事件
|
||
if (!isTaxRow) {
|
||
const recalc = () => handleTax(tr);
|
||
tr.querySelector(".debit").addEventListener("input", recalc);
|
||
tr.querySelector(".credit").addEventListener("input", recalc);
|
||
tr.querySelector(".taxType").addEventListener("change", recalc);
|
||
tr.querySelector(".taxDirection").addEventListener("change", recalc);
|
||
}
|
||
|
||
return tr;
|
||
}
|
||
|
||
// 删除该行的所有税行
|
||
function removeTaxRowsOf(row) {
|
||
const id = row.dataset.rowId;
|
||
if (!id) return;
|
||
document.querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`).forEach(r => r.remove());
|
||
}
|
||
|
||
function removeRow(btn) {
|
||
const row = btn.closest("tr");
|
||
removeTaxRowsOf(row);
|
||
row.remove();
|
||
}
|
||
|
||
|
||
// -----------------------------
|
||
// 税行 自動生成/更新(10%/8%のみ、四捨五入)
|
||
// -----------------------------
|
||
function handleTax(row) {
|
||
// 清理旧税行
|
||
removeTaxRowsOf(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 totalAmount = debit || credit;
|
||
if (!totalAmount) return;
|
||
|
||
const rate = taxType === "8" ? 0.08 : 0.10;
|
||
const roundingMode = document.getElementById("roundingMode").value;
|
||
|
||
// 税込 → 税額逆算
|
||
const rawTax = totalAmount * rate / (1 + rate);
|
||
let taxAmount;
|
||
switch (roundingMode) {
|
||
case "floor": taxAmount = Math.floor(rawTax); break;
|
||
case "ceil": taxAmount = Math.ceil(rawTax); break;
|
||
default: taxAmount = Math.round(rawTax);
|
||
}
|
||
|
||
const parentId = row.dataset.rowId;
|
||
|
||
if (taxDir === "paid") {
|
||
const taxAcc = document.getElementById("taxPaidSelect").value;
|
||
if (!taxAcc) return alert("仮払消費税等 勘定を選択してください");
|
||
addRow(true, { accountId: taxAcc, amount: taxAmount, side: "debit" }, parentId);
|
||
}
|
||
if (taxDir === "received") {
|
||
const taxAcc = document.getElementById("taxReceivedSelect").value;
|
||
if (!taxAcc) return alert("仮受消費税等 勘定を選択してください");
|
||
addRow(true, { accountId: taxAcc, amount: taxAmount, side: "credit" }, parentId);
|
||
}
|
||
}
|
||
|
||
|
||
|
||
|
||
|
||
// 税行は常に「直後の1行」を使う
|
||
function removeTaxRow(row) {
|
||
const id = row.dataset.rowId;
|
||
if (!id) return;
|
||
|
||
document
|
||
.querySelectorAll(`tr.tax-row[data-parent-id="${id}"]`)
|
||
.forEach(tr => tr.remove());
|
||
}
|
||
|
||
function removeRow(btn) {
|
||
const row = btn.closest("tr");
|
||
removeTaxRow(row);
|
||
row.remove();
|
||
}
|
||
|
||
// -----------------------------
|
||
// 登録(最小可用版)
|
||
// -----------------------------
|
||
async function submitJournal() {
|
||
const entryDate = document.getElementById("entryDate").value;
|
||
const description = document.getElementById("description").value.trim();
|
||
|
||
if (!entryDate) { alert("仕訳日を入力してください"); return; }
|
||
|
||
// 明細収集
|
||
const rows = document.querySelectorAll("#linesTable tbody tr");
|
||
const lines = [];
|
||
let totalDebit = 0, totalCredit = 0;
|
||
|
||
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;
|
||
|
||
lines.push({ account_id: accountId, debit: debit, credit: credit });
|
||
totalDebit += debit;
|
||
totalCredit += credit;
|
||
});
|
||
|
||
if (lines.length === 0) { alert("仕訳明細がありません"); return; }
|
||
|
||
// 借貸一致チェック(最小)
|
||
if (totalDebit !== totalCredit) {
|
||
alert(`借方(${totalDebit.toLocaleString()})と貸方(${totalCredit.toLocaleString()})が一致しません`);
|
||
return;
|
||
}
|
||
|
||
const payload = { entry_date: entryDate, description, lines };
|
||
|
||
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();
|
||
}
|
||
|
||
|
||
|
||
|
||
const API_BASE = "http://127.0.0.1:18080";
|
||
|
||
async function callApi(path) {
|
||
try {
|
||
const res = await fetch(`${API_BASE}${path}`, { method: "POST" });
|
||
const text = await res.text();
|
||
let data = {};
|
||
try { data = text ? JSON.parse(text) : {}; } catch { data = { detail: text }; }
|
||
|
||
if (!res.ok) {
|
||
throw new Error(data.detail || `HTTP ${res.status}`);
|
||
}
|
||
return data;
|
||
} catch (e) {
|
||
throw new Error(e.message || "请求失败");
|
||
}
|
||
}
|
||
|
||
async function lockMonth() {
|
||
const year = document.getElementById("lockYear").value;
|
||
const month = document.getElementById("lockMonth").value;
|
||
|
||
const el = document.getElementById("lockResult");
|
||
el.innerText = "处理中...";
|
||
|
||
try {
|
||
await callApi(`/month-locks/lock?fiscal_year=${year}&month=${month}`);
|
||
el.innerText = `✅ ${year}年${month}月 已锁定`;
|
||
await refreshMonthLocks(); // 可选:自动刷新列表
|
||
} catch (e) {
|
||
el.innerText = `❌ 错误:${e.message}`;
|
||
}
|
||
}
|
||
|
||
async function unlockMonth() {
|
||
const year = document.getElementById("lockYear").value;
|
||
const month = document.getElementById("lockMonth").value;
|
||
|
||
const el = document.getElementById("lockResult");
|
||
el.innerText = "处理中...";
|
||
|
||
try {
|
||
await callApi(`/month-locks/unlock?fiscal_year=${year}&month=${month}`);
|
||
el.innerText = `✅ ${year}年${month}月 已解锁`;
|
||
await refreshMonthLocks(); // 可选:自动刷新列表
|
||
} catch (e) {
|
||
el.innerText = `❌ 错误:${e.message}`;
|
||
}
|
||
}
|
||
|
||
|
||
async function refreshMonthLocks() {
|
||
const el = document.getElementById("lockList");
|
||
el.textContent = "读取中...";
|
||
|
||
try {
|
||
const res = await fetch(`${API_BASE}/month-locks`);
|
||
const data = await res.json();
|
||
|
||
// 只显示 locked 的,避免太长
|
||
const locked = (data || []).filter(x => x.is_locked);
|
||
|
||
if (locked.length === 0) {
|
||
el.textContent = "(当前没有锁定月份)";
|
||
return;
|
||
}
|
||
|
||
el.textContent = locked
|
||
.map(x => `${x.fiscal_year}-${String(x.month).padStart(2, "0")} locked_by=${x.locked_by ?? ""}`)
|
||
.join("\n");
|
||
} catch (e) {
|
||
el.textContent = `读取失败:${e.message || e}`;
|
||
}
|
||
}
|
||
|
||
|
||
|
||
async function checkMonthLockByDate(dateStr) {
|
||
if (!dateStr) return false;
|
||
|
||
const d = new Date(dateStr);
|
||
const year = d.getFullYear();
|
||
const month = d.getMonth() + 1;
|
||
|
||
const res = await fetch(`${API_BASE}/month-locks`);
|
||
const data = await res.json();
|
||
|
||
return (data || []).some(
|
||
x => x.fiscal_year === year && x.month === month && x.is_locked
|
||
);
|
||
}
|
||
|
||
async function onEntryDateChanged() {
|
||
const dateStr = document.getElementById("entryDate").value;
|
||
const locked = await checkMonthLockByDate(dateStr);
|
||
|
||
const banner = document.getElementById("monthLockBanner");
|
||
|
||
if (locked) {
|
||
banner.style.display = "block";
|
||
setJournalReadonly(true);
|
||
} else {
|
||
banner.style.display = "none";
|
||
setJournalReadonly(false);
|
||
}
|
||
}
|
||
|
||
|
||
function setJournalReadonly(readonly) {
|
||
// 保存按钮
|
||
const saveBtn = document.getElementById("saveButton");
|
||
if (saveBtn) saveBtn.disabled = readonly;
|
||
|
||
// 行追加按钮
|
||
const addRowBtn = document.getElementById("addRowButton");
|
||
if (addRowBtn) addRowBtn.disabled = readonly;
|
||
|
||
// 所有“删除行”按钮
|
||
document.querySelectorAll(".delete-row-btn").forEach(btn => {
|
||
btn.disabled = readonly;
|
||
});
|
||
|
||
// 所有仕訳输入项(金额 / 科目等)
|
||
document.querySelectorAll(".journal-input").forEach(input => {
|
||
input.disabled = readonly;
|
||
});
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|