修正
This commit is contained in:
@@ -1,232 +1,50 @@
|
|||||||
from fastapi import APIRouter, HTTPException, Query
|
from fastapi import APIRouter, HTTPException
|
||||||
from typing import Optional
|
from app.core.database import get_connection
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from typing import List
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from datetime import date
|
from datetime import date
|
||||||
|
|
||||||
from app.core.database import get_connection
|
|
||||||
from app.models.journal_entry import JournalEntryRequest
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/journal-entries", tags=["仕訳"])
|
router = APIRouter(prefix="/journal-entries", tags=["仕訳"])
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
class JournalLine(BaseModel):
|
||||||
# 仕訳登録
|
account_id: int
|
||||||
# -------------------------------------------------
|
debit: Decimal = Decimal("0")
|
||||||
@router.post("", summary="仕訳登録")
|
credit: Decimal = Decimal("0")
|
||||||
|
|
||||||
|
|
||||||
|
class JournalEntryRequest(BaseModel):
|
||||||
|
entry_date: date
|
||||||
|
description: str
|
||||||
|
lines: List[JournalLine]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("")
|
||||||
def create_journal_entry(req: JournalEntryRequest):
|
def create_journal_entry(req: JournalEntryRequest):
|
||||||
|
with get_connection() as conn:
|
||||||
if len(req.lines) < 2:
|
with conn.cursor() as cur:
|
||||||
raise HTTPException(status_code=400, detail="仕訳行は2行以上必要です")
|
# 仕訳ヘッダ
|
||||||
|
|
||||||
if req.entry_date < date(2025, 6, 1):
|
|
||||||
raise HTTPException(status_code=400, detail="仕訳日は2025-06-01以降である必要があります")
|
|
||||||
|
|
||||||
debit_total = Decimal("0")
|
|
||||||
credit_total = Decimal("0")
|
|
||||||
|
|
||||||
for line in req.lines:
|
|
||||||
if line.debit < 0 or line.credit < 0:
|
|
||||||
raise HTTPException(status_code=400, detail="金額は正数で入力してください")
|
|
||||||
|
|
||||||
if line.debit > 0 and line.credit > 0:
|
|
||||||
raise HTTPException(status_code=400, detail="同一行で借方・貸方の同時入力は不可です")
|
|
||||||
|
|
||||||
debit_total += line.debit
|
|
||||||
credit_total += line.credit
|
|
||||||
|
|
||||||
if debit_total != credit_total:
|
|
||||||
raise HTTPException(
|
|
||||||
status_code=400,
|
|
||||||
detail=f"借方合計({debit_total})と貸方合計({credit_total})が一致しません"
|
|
||||||
)
|
|
||||||
|
|
||||||
with get_connection() as conn, conn.cursor() as cur:
|
|
||||||
|
|
||||||
# 期首ロック確認
|
|
||||||
cur.execute("""
|
|
||||||
SELECT is_locked
|
|
||||||
FROM opening_balance_locks
|
|
||||||
WHERE fiscal_year = 2025
|
|
||||||
""")
|
|
||||||
row = cur.fetchone()
|
|
||||||
if not row or not row[0]:
|
|
||||||
raise HTTPException(status_code=400, detail="期首残高が確定(lock)されていません")
|
|
||||||
|
|
||||||
# ヘッダ登録
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO journal_entries (entry_date, description)
|
|
||||||
VALUES (%s, %s)
|
|
||||||
RETURNING journal_entry_id
|
|
||||||
""", (req.entry_date, req.description))
|
|
||||||
journal_entry_id = cur.fetchone()[0]
|
|
||||||
|
|
||||||
# 明細登録
|
|
||||||
for line in req.lines:
|
|
||||||
cur.execute("""
|
cur.execute("""
|
||||||
INSERT INTO journal_lines
|
INSERT INTO journal_entries (entry_date, description)
|
||||||
(journal_entry_id, account_id, debit, credit)
|
VALUES (%s, %s)
|
||||||
VALUES (%s, %s, %s, %s)
|
RETURNING id
|
||||||
""", (
|
""", (req.entry_date, req.description))
|
||||||
journal_entry_id,
|
entry_id = cur.fetchone()[0]
|
||||||
line.account_id,
|
|
||||||
line.debit,
|
|
||||||
line.credit
|
|
||||||
))
|
|
||||||
|
|
||||||
return {
|
# 明細
|
||||||
"message": "仕訳を登録しました",
|
for line in req.lines:
|
||||||
"journal_entry_id": journal_entry_id
|
cur.execute("""
|
||||||
}
|
INSERT INTO journal_lines
|
||||||
|
(journal_entry_id, account_id, debit, credit)
|
||||||
|
VALUES (%s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
entry_id,
|
||||||
|
line.account_id,
|
||||||
|
line.debit,
|
||||||
|
line.credit
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
# -------------------------------------------------
|
return {"status": "ok", "journal_entry_id": entry_id}
|
||||||
# 仕訳一覧取得
|
|
||||||
# -------------------------------------------------
|
|
||||||
@router.get("", summary="仕訳一覧取得")
|
|
||||||
def list_journal_entries(
|
|
||||||
from_date: Optional[str] = Query(None),
|
|
||||||
to_date: Optional[str] = Query(None),
|
|
||||||
keyword: Optional[str] = Query(None)
|
|
||||||
):
|
|
||||||
sql = """
|
|
||||||
SELECT
|
|
||||||
je.journal_entry_id,
|
|
||||||
je.entry_date,
|
|
||||||
je.description,
|
|
||||||
SUM(jl.debit) AS debit_total,
|
|
||||||
SUM(jl.credit) AS credit_total
|
|
||||||
FROM journal_entries je
|
|
||||||
JOIN journal_lines jl
|
|
||||||
ON jl.journal_entry_id = je.journal_entry_id
|
|
||||||
WHERE 1=1
|
|
||||||
"""
|
|
||||||
params = []
|
|
||||||
|
|
||||||
if from_date:
|
|
||||||
sql += " AND je.entry_date >= %s"
|
|
||||||
params.append(from_date)
|
|
||||||
if to_date:
|
|
||||||
sql += " AND je.entry_date <= %s"
|
|
||||||
params.append(to_date)
|
|
||||||
if keyword:
|
|
||||||
sql += " AND je.description ILIKE %s"
|
|
||||||
params.append(f"%{keyword}%")
|
|
||||||
|
|
||||||
sql += """
|
|
||||||
GROUP BY je.journal_entry_id, je.entry_date, je.description
|
|
||||||
ORDER BY je.entry_date DESC, je.journal_entry_id DESC
|
|
||||||
"""
|
|
||||||
|
|
||||||
with get_connection() as conn, conn.cursor() as cur:
|
|
||||||
cur.execute(sql, params)
|
|
||||||
rows = cur.fetchall()
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
"journal_entry_id": r[0],
|
|
||||||
"entry_date": r[1],
|
|
||||||
"description": r[2],
|
|
||||||
"debit_total": r[3],
|
|
||||||
"credit_total": r[4],
|
|
||||||
}
|
|
||||||
for r in rows
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
|
||||||
# 仕訳明細取得
|
|
||||||
# -------------------------------------------------
|
|
||||||
@router.get("/{journal_entry_id}", summary="仕訳明細取得")
|
|
||||||
def get_journal_entry(journal_entry_id: int):
|
|
||||||
with get_connection() as conn, conn.cursor() as cur:
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT entry_date, description
|
|
||||||
FROM journal_entries
|
|
||||||
WHERE journal_entry_id = %s
|
|
||||||
""", (journal_entry_id,))
|
|
||||||
header = cur.fetchone()
|
|
||||||
if not header:
|
|
||||||
raise HTTPException(status_code=404, detail="仕訳が存在しません")
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT
|
|
||||||
jl.account_id,
|
|
||||||
a.account_code,
|
|
||||||
a.account_name,
|
|
||||||
jl.debit,
|
|
||||||
jl.credit
|
|
||||||
FROM journal_lines jl
|
|
||||||
JOIN accounts a ON a.account_id = jl.account_id
|
|
||||||
WHERE jl.journal_entry_id = %s
|
|
||||||
""", (journal_entry_id,))
|
|
||||||
lines = cur.fetchall()
|
|
||||||
|
|
||||||
return {
|
|
||||||
"entry_date": header[0],
|
|
||||||
"description": header[1],
|
|
||||||
"lines": [
|
|
||||||
{
|
|
||||||
"account_id": l[0],
|
|
||||||
"account_code": l[1],
|
|
||||||
"account_name": l[2],
|
|
||||||
"debit": l[3],
|
|
||||||
"credit": l[4]
|
|
||||||
} for l in lines
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------------------------
|
|
||||||
# 逆仕訳生成(修正仕訳)
|
|
||||||
# -------------------------------------------------
|
|
||||||
@router.post("/{journal_entry_id}/reverse", summary="逆仕訳生成")
|
|
||||||
def reverse_journal_entry(journal_entry_id: int):
|
|
||||||
with get_connection() as conn, conn.cursor() as cur:
|
|
||||||
|
|
||||||
# 元仕訳取得
|
|
||||||
cur.execute("""
|
|
||||||
SELECT entry_date, description
|
|
||||||
FROM journal_entries
|
|
||||||
WHERE journal_entry_id = %s
|
|
||||||
""", (journal_entry_id,))
|
|
||||||
header = cur.fetchone()
|
|
||||||
if not header:
|
|
||||||
raise HTTPException(status_code=404, detail="仕訳が存在しません")
|
|
||||||
|
|
||||||
entry_date, description = header
|
|
||||||
|
|
||||||
cur.execute("""
|
|
||||||
SELECT account_id, debit, credit
|
|
||||||
FROM journal_lines
|
|
||||||
WHERE journal_entry_id = %s
|
|
||||||
""", (journal_entry_id,))
|
|
||||||
lines = cur.fetchall()
|
|
||||||
if not lines:
|
|
||||||
raise HTTPException(status_code=400, detail="仕訳明細が存在しません")
|
|
||||||
|
|
||||||
# 逆仕訳ヘッダ
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO journal_entries (entry_date, description)
|
|
||||||
VALUES (%s, %s)
|
|
||||||
RETURNING journal_entry_id
|
|
||||||
""", (entry_date, f"【修正】{description}"))
|
|
||||||
new_entry_id = cur.fetchone()[0]
|
|
||||||
|
|
||||||
# 逆仕訳明細(借贷反転)
|
|
||||||
for account_id, debit, credit in lines:
|
|
||||||
cur.execute("""
|
|
||||||
INSERT INTO journal_lines
|
|
||||||
(journal_entry_id, account_id, debit, credit)
|
|
||||||
VALUES (%s, %s, %s, %s)
|
|
||||||
""", (
|
|
||||||
new_entry_id,
|
|
||||||
account_id,
|
|
||||||
credit,
|
|
||||||
debit
|
|
||||||
))
|
|
||||||
|
|
||||||
return {
|
|
||||||
"message": "逆仕訳を作成しました",
|
|
||||||
"reversed_journal_entry_id": new_entry_id
|
|
||||||
}
|
|
||||||
|
|||||||
196
frontend/journal-edit.html
Normal file
196
frontend/journal-edit.html
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
<!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; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<h2>修正仕訳入力</h2>
|
||||||
|
|
||||||
|
<label>仕訳日</label>
|
||||||
|
<input type="date" id="entryDate">
|
||||||
|
|
||||||
|
<label>摘要</label>
|
||||||
|
<input type="text" id="description" style="width:60%">
|
||||||
|
|
||||||
|
<table id="linesTable">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<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></th>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<button onclick="addRow()">+ 行追加</button>
|
||||||
|
<button onclick="submitEntry()">修正版仕訳を登録</button>
|
||||||
|
|
||||||
|
<div id="result"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const API = "http://127.0.0.1:18080";
|
||||||
|
let accounts = [];
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 初期化
|
||||||
|
// ------------------------------------
|
||||||
|
async function init() {
|
||||||
|
// 科目取得
|
||||||
|
const res = await fetch(`${API}/accounts`);
|
||||||
|
accounts = await res.json();
|
||||||
|
|
||||||
|
// 元仕訳取得
|
||||||
|
const src = localStorage.getItem("editSource");
|
||||||
|
if (!src) {
|
||||||
|
showError("修正元の仕訳データが見つかりません");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = JSON.parse(src);
|
||||||
|
|
||||||
|
// ヘッダ反映
|
||||||
|
document.getElementById("entryDate").value = data.entry_date;
|
||||||
|
document.getElementById("description").value = `【修正後】${data.description}`;
|
||||||
|
|
||||||
|
// 明細反映
|
||||||
|
data.lines.forEach(l => addRow(l));
|
||||||
|
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
init();
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 行操作
|
||||||
|
// ------------------------------------
|
||||||
|
function addRow(line = null) {
|
||||||
|
const tbody = document.querySelector("#linesTable tbody");
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
|
||||||
|
tr.innerHTML = `
|
||||||
|
<td>
|
||||||
|
<select>
|
||||||
|
${accounts.map(a =>
|
||||||
|
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
|
||||||
|
).join("")}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td><input type="number" min="0"></td>
|
||||||
|
<td><input type="number" min="0"></td>
|
||||||
|
<td><button onclick="removeRow(this)">削除</button></td>
|
||||||
|
`;
|
||||||
|
|
||||||
|
tbody.appendChild(tr);
|
||||||
|
|
||||||
|
if (line) {
|
||||||
|
tr.querySelector("select").value = line.account_id;
|
||||||
|
tr.querySelectorAll("input")[0].value = line.debit || "";
|
||||||
|
tr.querySelectorAll("input")[1].value = line.credit || "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow(btn) {
|
||||||
|
btn.closest("tr").remove();
|
||||||
|
recalc();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 合計再計算
|
||||||
|
// ------------------------------------
|
||||||
|
function recalc() {
|
||||||
|
let d = 0, c = 0;
|
||||||
|
document.querySelectorAll("#linesTable tbody tr").forEach(tr => {
|
||||||
|
d += Number(tr.querySelectorAll("input")[0].value || 0);
|
||||||
|
c += Number(tr.querySelectorAll("input")[1].value || 0);
|
||||||
|
});
|
||||||
|
document.getElementById("debitTotal").textContent = d.toLocaleString();
|
||||||
|
document.getElementById("creditTotal").textContent = c.toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------
|
||||||
|
// 登録
|
||||||
|
// ------------------------------------
|
||||||
|
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 || !desc) {
|
||||||
|
showError("日付と摘要は必須です");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
let d = 0, c = 0;
|
||||||
|
|
||||||
|
document.querySelectorAll("#linesTable tbody tr").forEach(tr => {
|
||||||
|
const accountId = Number(tr.querySelector("select").value);
|
||||||
|
const debit = Number(tr.querySelectorAll("input")[0].value || 0);
|
||||||
|
const credit = Number(tr.querySelectorAll("input")[1].value || 0);
|
||||||
|
if (debit === 0 && credit === 0) return;
|
||||||
|
lines.push({ account_id: accountId, debit, credit });
|
||||||
|
d += debit;
|
||||||
|
c += credit;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lines.length < 2) return showError("仕訳行は2行以上必要です");
|
||||||
|
if (d !== c) return showError("借方合計と貸方合計が一致していません");
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
entry_date: entryDate,
|
||||||
|
description: desc,
|
||||||
|
lines
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/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})`);
|
||||||
|
localStorage.removeItem("editSource");
|
||||||
|
} 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";
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -1,54 +1,41 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ja">
|
<html lang="ja">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<title>仕訳入力(複数行・消費税自動)</title>
|
<title>仕訳入力</title>
|
||||||
<style>
|
<style>
|
||||||
body { font-family: sans-serif; margin: 24px; }
|
body { font-family: sans-serif; margin: 24px; }
|
||||||
table { border-collapse: collapse; width: 100%; margin-top: 12px; }
|
h2 { margin: 0 0 12px; }
|
||||||
th, td { border: 1px solid #ccc; padding: 6px; text-align: center; }
|
table { border-collapse: collapse; width: 100%; margin-top: 12px; }
|
||||||
th { background: #f5f5f5; }
|
th, td { border: 1px solid #ccc; padding: 6px; text-align: center; }
|
||||||
input, select, button { padding: 6px; }
|
th { background: #f5f5f5; }
|
||||||
.right { text-align: right; }
|
input, select, button { padding: 6px; }
|
||||||
.error { color: #c00; margin-top: 8px; }
|
.right { text-align: right; }
|
||||||
.ok { color: #090; margin-top: 8px; }
|
.row { margin: 8px 0; display: flex; gap: 16px; align-items: center; }
|
||||||
.row { display: flex; gap: 12px; align-items: center; margin: 8px 0; }
|
.row label { min-width: 120px; }
|
||||||
.row > * { flex: 1; }
|
.tax-row { background: #f0f8ff; }
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<h2><div class="row">
|
<h2>仕訳入力</h2>
|
||||||
<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 class="row">
|
||||||
<div>
|
<label>仕訳日</label>
|
||||||
<label>仕訳日</label>
|
<input type="date" id="entryDate" />
|
||||||
<input type="date" id="entryDate" />
|
|
||||||
</div>
|
|
||||||
<div style="flex:2">
|
|
||||||
<label>摘要</label>
|
|
||||||
<input type="text" id="description" placeholder="例:売上計上/経費支払" style="width:100%" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div>
|
<label>摘要</label>
|
||||||
<label>仮払消費税等 勘定</label>
|
<input type="text" id="description" style="flex:1" placeholder="例:売上入金/経費支払 など" />
|
||||||
<select id="taxDebitAccount"></select>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
<div class="row">
|
||||||
<label>仮受消費税等 勘定</label>
|
<label>仮払消費税等 勘定</label>
|
||||||
<select id="taxCreditAccount"></select>
|
<select id="taxPaidSelect"></select>
|
||||||
</div>
|
|
||||||
|
<label>仮受消費税等 勘定</label>
|
||||||
|
<select id="taxReceivedSelect"></select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table id="linesTable">
|
<table id="linesTable">
|
||||||
@@ -63,261 +50,210 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<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>
|
</table>
|
||||||
|
|
||||||
<button onclick="addRow()">+ 行追加</button>
|
<div style="margin-top:10px; display:flex; gap:8px;">
|
||||||
<button onclick="submitEntry()">登録</button>
|
<button onclick="addRow()">+ 行追加</button>
|
||||||
|
<button onclick="submitJournal()">登録</button>
|
||||||
<div id="result"></div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
const API_BASE = "http://127.0.0.1:18080";
|
const API = "http://127.0.0.1:18080";
|
||||||
|
|
||||||
let accounts = [];
|
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() {
|
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();
|
accounts = await res.json();
|
||||||
|
|
||||||
// 税勘定候補を抽出
|
// 消費税用勘定を抽出(名前は「仮払」「仮受」を含む想定)
|
||||||
const taxDebitSel = document.getElementById("taxDebitAccount");
|
// 仮払消費税等:资产 + code 504(或你实际的 code)
|
||||||
const taxCreditSel = document.getElementById("taxCreditAccount");
|
taxPaidAccounts = accounts.filter(
|
||||||
|
a => a.account_code === "504"
|
||||||
|
);
|
||||||
|
|
||||||
const taxDebitCandidates = accounts.filter(a => a.account_name.includes("仮払消費税"));
|
// 仮受消費税等:负债 + code 505(或你实际的 code)
|
||||||
const taxCreditCandidates = accounts.filter(a => a.account_name.includes("仮受消費税"));
|
taxReceivedAccounts = accounts.filter(
|
||||||
|
a => a.account_code === "505"
|
||||||
|
);
|
||||||
|
|
||||||
// 仮払
|
|
||||||
fillSelect(taxDebitSel, taxDebitCandidates.length ? taxDebitCandidates : accounts);
|
|
||||||
// 仮受
|
|
||||||
fillSelect(taxCreditSel, taxCreditCandidates.length ? taxCreditCandidates : accounts);
|
|
||||||
|
|
||||||
|
setupTaxSelectors();
|
||||||
addRow();
|
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");
|
function addRow(isTaxRow = false, taxInfo = null) {
|
||||||
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 tbody = document.querySelector("#linesTable tbody");
|
||||||
const tr = document.createElement("tr");
|
const tr = document.createElement("tr");
|
||||||
|
if (isTaxRow) tr.classList.add("tax-row");
|
||||||
|
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>
|
<td>
|
||||||
<select class="account">
|
<select class="account" ${isTaxRow ? "disabled" : ""}>
|
||||||
${accounts.map(a =>
|
${accounts.map(a =>
|
||||||
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
|
`<option value="${a.account_id}">${a.account_code} ${a.account_name}</option>`
|
||||||
).join("")}
|
).join("")}
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td><input type="number" min="0" class="debit" oninput="onDebitInput(this)" /></td>
|
<td><input type="number" class="debit right" min="0" ${isTaxRow ? "readonly" : ""}></td>
|
||||||
<td><input type="number" min="0" class="credit" oninput="onCreditInput(this)" /></td>
|
<td><input type="number" class="credit right" min="0" ${isTaxRow ? "readonly" : ""}></td>
|
||||||
<td>
|
<td>
|
||||||
<select class="tax">
|
<select class="taxType" ${isTaxRow ? "disabled" : ""}>
|
||||||
${TAX_OPTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
|
<option value="none">対象外</option>
|
||||||
|
<option value="10">10%</option>
|
||||||
|
<option value="8">8%</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select class="taxdir">
|
<select class="taxDirection" ${isTaxRow ? "disabled" : ""}>
|
||||||
${TAX_DIRECTIONS.map(t => `<option value="${t.value}">${t.label}</option>`).join("")}
|
<option value="none">対象外</option>
|
||||||
|
<option value="paid">仮払(仕入・経費)</option>
|
||||||
|
<option value="received">仮受(売上)</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td><button onclick="removeRow(this)">削除</button></td>
|
<td>${isTaxRow ? "" : `<button onclick="removeRow(this)">削除</button>`}</td>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
tbody.appendChild(tr);
|
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) {
|
function removeRow(btn) {
|
||||||
btn.closest("tr").remove();
|
const row = btn.closest("tr");
|
||||||
recalc();
|
removeTaxRow(row);
|
||||||
|
row.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDebitInput(input) {
|
// -----------------------------
|
||||||
const credit = input.closest("tr").querySelector(".credit");
|
// 登録(最小可用版)
|
||||||
if (input.value) credit.value = "";
|
// -----------------------------
|
||||||
recalc();
|
async function submitJournal() {
|
||||||
}
|
|
||||||
|
|
||||||
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 entryDate = document.getElementById("entryDate").value;
|
||||||
const desc = document.getElementById("description").value;
|
const description = document.getElementById("description").value.trim();
|
||||||
if (!entryDate) return showError("仕訳日を入力してください");
|
|
||||||
if (!desc) return showError("摘要を入力してください");
|
|
||||||
|
|
||||||
const taxDebitAccountId = Number(document.getElementById("taxDebitAccount").value);
|
if (!entryDate) { alert("仕訳日を入力してください"); return; }
|
||||||
const taxCreditAccountId = Number(document.getElementById("taxCreditAccount").value);
|
|
||||||
|
|
||||||
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 => {
|
rows.forEach(row => {
|
||||||
const accountId = Number(tr.querySelector(".account").value);
|
const accountId = Number(row.querySelector(".account").value);
|
||||||
const debit = Number(tr.querySelector(".debit").value || 0);
|
const debit = Number(row.querySelector(".debit").value || 0);
|
||||||
const credit = Number(tr.querySelector(".credit").value || 0);
|
const credit = Number(row.querySelector(".credit").value || 0);
|
||||||
const taxOpt = tr.querySelector(".tax").value;
|
|
||||||
const taxDir = tr.querySelector(".taxdir").value;
|
|
||||||
|
|
||||||
// 空行は無視
|
|
||||||
if (debit === 0 && credit === 0) return;
|
if (debit === 0 && credit === 0) return;
|
||||||
|
|
||||||
const { rate, inclusive } = parseTaxOption(taxOpt);
|
lines.push({ account_id: accountId, debit: debit, credit: credit });
|
||||||
|
totalDebit += debit;
|
||||||
if (debit > 0) {
|
totalCredit += credit;
|
||||||
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 === 0) { alert("仕訳明細がありません"); return; }
|
||||||
|
|
||||||
if (lines.length < 2)
|
// 借貸一致チェック(最小)
|
||||||
return showError("仕訳行は2行以上必要です");
|
if (totalDebit !== totalCredit) {
|
||||||
|
alert(`借方(${totalDebit.toLocaleString()})と貸方(${totalCredit.toLocaleString()})が一致しません`);
|
||||||
// 借貸チェック(送信前に最終確認)
|
return;
|
||||||
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 payload = { entry_date: entryDate, description, lines };
|
||||||
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 res = await fetch(`${API}/journal-entries`, {
|
||||||
document.addEventListener("input", () => recalc());
|
method: "POST",
|
||||||
init();
|
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>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
<th>日付</th>
|
<th>日付</th>
|
||||||
<th>摘要</th>
|
<th>摘要</th>
|
||||||
<th>借方合計</th>
|
<th>借方合計</th>
|
||||||
<th>贷方合计</th>
|
<th>貸方合計</th>
|
||||||
<th>操作</th>
|
<th>操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -63,7 +63,7 @@ async function search() {
|
|||||||
<td style="text-align:right">${e.credit_total.toLocaleString()}</td>
|
<td style="text-align:right">${e.credit_total.toLocaleString()}</td>
|
||||||
<td>
|
<td>
|
||||||
<button onclick="view(${e.journal_entry_id})">表示</button>
|
<button onclick="view(${e.journal_entry_id})">表示</button>
|
||||||
<button onclick="reverse(${e.journal_entry_id})">修正</button>
|
<button onclick="reverseAndEdit(${e.journal_entry_id})">修正</button>
|
||||||
</td>
|
</td>
|
||||||
`;
|
`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
@@ -74,23 +74,34 @@ function view(id) {
|
|||||||
window.open(`journal-view.html?id=${id}`, "_blank");
|
window.open(`journal-view.html?id=${id}`, "_blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reverse(id) {
|
// --------------------------------------------------
|
||||||
if (!confirm("この仕訳の逆仕訳を作成します。よろしいですか?")) return;
|
// 修正仕訳:逆仕訳を作って修正画面へ遷移
|
||||||
|
// --------------------------------------------------
|
||||||
|
async function reverseAndEdit(id) {
|
||||||
|
if (!confirm("この仕訳の逆仕訳を作成し、修正仕訳入力画面を開きます。よろしいですか?")) return;
|
||||||
|
|
||||||
|
// ① 逆仕訳を作成
|
||||||
const res = await fetch(`${API}/journal-entries/${id}/reverse`, {
|
const res = await fetch(`${API}/journal-entries/${id}/reverse`, {
|
||||||
method: "POST"
|
method: "POST"
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
alert(data.detail || "逆仕訳の作成に失敗しました");
|
alert(data.detail || "逆仕訳の作成に失敗しました");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
alert(`逆仕訳を作成しました(ID: ${data.reversed_journal_entry_id})`);
|
alert(`逆仕訳を作成しました(ID: ${data.reversed_journal_entry_id})`);
|
||||||
search();
|
|
||||||
|
// ② 元仕訳内容を取得して修正画面へ遷移
|
||||||
|
const src = await fetch(`${API}/journal-entries/${id}`);
|
||||||
|
const srcData = await src.json();
|
||||||
|
|
||||||
|
localStorage.setItem("editSource", JSON.stringify(srcData));
|
||||||
|
|
||||||
|
window.open("journal-edit.html", "_blank");
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
172
frontend/js/journal-entry.js
Normal file
172
frontend/js/journal-entry.js
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
// === 仕訳登録 画面用ユーティリティ ===
|
||||||
|
|
||||||
|
// 税率を permil(‰)で表現:10%→100‰, 8%→80‰
|
||||||
|
// 小数誤差を避けるため、整数演算ベースで処理
|
||||||
|
const TAX_PERMIL = {
|
||||||
|
"10": 100, // 10%
|
||||||
|
"8": 80 // 8%
|
||||||
|
};
|
||||||
|
|
||||||
|
// 補助: 四捨五入(整数)
|
||||||
|
function roundInt(x) {
|
||||||
|
return Math.round(x);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 補助: 税抜 = round( 税込 × 1000 / (1000 + ratePermil) )
|
||||||
|
function calcExclFromIncl(inclAmount, ratePermil) {
|
||||||
|
const denom = 1000 + ratePermil;
|
||||||
|
return roundInt((inclAmount * 1000) / denom);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 補助: 税額 = 税込 − 税抜(税込入力時)
|
||||||
|
function calcTaxFromIncl(inclAmount, ratePermil) {
|
||||||
|
const excl = calcExclFromIncl(inclAmount, ratePermil);
|
||||||
|
return inclAmount - excl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 補助: 税額 = round( 税抜 × ratePermil / 1000 )(税抜入力時)
|
||||||
|
function calcTaxFromExcl(exclAmount, ratePermil) {
|
||||||
|
return roundInt((exclAmount * ratePermil) / 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仕訳行タイプ判定(会計実務ルール)
|
||||||
|
function isForceInclAccount(accountCodeOrName) {
|
||||||
|
// 売掛金/買掛金/未収入金/未払金 は税込固定
|
||||||
|
const keywords = ["売掛金", "買掛金", "未収入金", "未払金"];
|
||||||
|
return keywords.some(k => accountCodeOrName.includes(k));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 税方向→仮受(売上) or 仮払(仕入)
|
||||||
|
function taxAccountNameByDirection(direction) {
|
||||||
|
return direction === "仮受" ? "仮受消費税等" : "仮払消費税等";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 状態管理(税額手修正フラグ)
|
||||||
|
let manualTaxOverridden = false;
|
||||||
|
|
||||||
|
// UIイベント: 税額手修正が行われたら自動連動解除
|
||||||
|
function onTaxAmountEdited() {
|
||||||
|
manualTaxOverridden = true;
|
||||||
|
const badge = document.querySelector("#taxManualBadge");
|
||||||
|
if (badge) badge.style.display = "inline-block";
|
||||||
|
}
|
||||||
|
|
||||||
|
// メイン: 画面上の入力からプレビュー行を生成(複数税率もOK)
|
||||||
|
function buildJournalPreview({
|
||||||
|
lines, // [{accountId, accountName, dc, amount, taxRate, taxApplicable, subAccountId}]
|
||||||
|
taxMode, // "税込" | "税抜"
|
||||||
|
taxDirection // "仮受" | "仮払"
|
||||||
|
}) {
|
||||||
|
// 税率ごとに集計(補助科目単位で集約)
|
||||||
|
const buckets = new Map(); // key = `${rate}-${subAccountId || ''}-${taxDirection}`
|
||||||
|
|
||||||
|
let debitTotal = 0;
|
||||||
|
let creditTotal = 0;
|
||||||
|
|
||||||
|
const normalLines = []; // 税対象外や通常科目
|
||||||
|
|
||||||
|
for (const ln of lines) {
|
||||||
|
const isForceIncl = isForceInclAccount(ln.accountName);
|
||||||
|
const mode = isForceIncl ? "税込" : taxMode; // 強制税込
|
||||||
|
|
||||||
|
if (ln.dc === "D") debitTotal += ln.amount;
|
||||||
|
else creditTotal += ln.amount;
|
||||||
|
|
||||||
|
normalLines.push(ln);
|
||||||
|
|
||||||
|
if (!ln.taxApplicable || !ln.taxRate) continue; // 対象外は課税計算しない
|
||||||
|
|
||||||
|
const ratePermil = TAX_PERMIL[String(ln.taxRate)];
|
||||||
|
if (ratePermil == null) continue;
|
||||||
|
|
||||||
|
// 税額の計算(自動計算のみ。手修正済ならここで足さない)
|
||||||
|
if (!manualTaxOverridden) {
|
||||||
|
let taxAmount = 0;
|
||||||
|
|
||||||
|
if (mode === "税込") {
|
||||||
|
// 税抜 = round( 税込 ÷(1+税率) ) → 税額=差額
|
||||||
|
const excl = calcExclFromIncl(ln.amount, ratePermil);
|
||||||
|
taxAmount = ln.amount - excl;
|
||||||
|
} else {
|
||||||
|
// 税抜 → 税額 = round( 税抜 × 税率 )
|
||||||
|
taxAmount = calcTaxFromExcl(ln.amount, ratePermil);
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = `${ln.taxRate}-${ln.subAccountId || ""}-${taxDirection}`;
|
||||||
|
const prev = buckets.get(key) || 0;
|
||||||
|
buckets.set(key, prev + taxAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 税行を作成(税率・補助科目ごと・税方向ごとに1行)
|
||||||
|
const taxLines = [];
|
||||||
|
for (const [key, amt] of buckets.entries()) {
|
||||||
|
const [rate, subAccountId, direction] = key.split("-");
|
||||||
|
if (amt === 0) continue;
|
||||||
|
|
||||||
|
const taxAccount = taxAccountNameByDirection(direction);
|
||||||
|
// 仮受は「貸方」、仮払は「借方」
|
||||||
|
const dc = direction === "仮受" ? "C" : "D";
|
||||||
|
taxLines.push({
|
||||||
|
accountName: taxAccount,
|
||||||
|
dc,
|
||||||
|
amount: amt,
|
||||||
|
taxRate: Number(rate),
|
||||||
|
taxApplicable: true,
|
||||||
|
subAccountId: subAccountId || null,
|
||||||
|
locked: true, // 科目/税区分/方向ロック
|
||||||
|
amountEditable: true // 金額のみ編集可
|
||||||
|
});
|
||||||
|
|
||||||
|
if (dc === "D") debitTotal += amt; else creditTotal += amt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// プレビュー結果
|
||||||
|
return {
|
||||||
|
lines: [...normalLines, ...taxLines],
|
||||||
|
debitTotal,
|
||||||
|
creditTotal,
|
||||||
|
balanced: debitTotal === creditTotal
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登録ボタン押下時:サーバーに渡すDTOを組み立て
|
||||||
|
function buildPostPayload(uiState) {
|
||||||
|
const preview = buildJournalPreview(uiState);
|
||||||
|
|
||||||
|
// バリデーション:貸借一致
|
||||||
|
if (!preview.balanced && !manualTaxOverridden) {
|
||||||
|
throw new Error("貸借が一致していません。税額手修正が必要な場合は、税額を調整して再試行してください。");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 税行が必要なのに無い場合はエラー(対象外以外で税率がある)
|
||||||
|
const needTax = uiState.lines.some(l => l.taxApplicable && l.taxRate);
|
||||||
|
const hasTaxLine = preview.lines.some(l => l.accountName === "仮受消費税等" || l.accountName === "仮払消費税等");
|
||||||
|
if (needTax && !hasTaxLine && !manualTaxOverridden) {
|
||||||
|
throw new Error("課税対象ですが消費税行がありません。");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
taxMode: uiState.taxMode, // "税込" | "税抜"
|
||||||
|
taxDirection: uiState.taxDirection, // "仮受" | "仮払"
|
||||||
|
manualTaxOverridden, // trueなら後端は自動再計算しない
|
||||||
|
lines: preview.lines.map(l => ({
|
||||||
|
accountId: l.accountId,
|
||||||
|
accountName: l.accountName,
|
||||||
|
dc: l.dc, // "D" | "C"
|
||||||
|
amount: l.amount, // 円(整数)
|
||||||
|
taxApplicable: !!l.taxApplicable,
|
||||||
|
taxRate: l.taxRate || null, // 8/10/null
|
||||||
|
subAccountId: l.subAccountId || null,
|
||||||
|
isTaxLine: (l.accountName === "仮受消費税等" || l.accountName === "仮払消費税等"),
|
||||||
|
lockAccountMeta: !!l.locked,
|
||||||
|
amountEditable: !!l.amountEditable
|
||||||
|
}))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 画面初期化で税額手修正バッジ非表示
|
||||||
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
|
const badge = document.querySelector("#taxManualBadge");
|
||||||
|
if (badge) badge.style.display = "none";
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user