diff --git a/backend/app/routers/journal_entries.py b/backend/app/routers/journal_entries.py
index 826056e..ae249e3 100644
--- a/backend/app/routers/journal_entries.py
+++ b/backend/app/routers/journal_entries.py
@@ -1,232 +1,50 @@
-from fastapi import APIRouter, HTTPException, Query
-from typing import Optional
+from fastapi import APIRouter, HTTPException
+from app.core.database import get_connection
+from pydantic import BaseModel
+from typing import List
from decimal import Decimal
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.post("", summary="仕訳登録")
+class JournalLine(BaseModel):
+ account_id: int
+ debit: Decimal = Decimal("0")
+ credit: Decimal = Decimal("0")
+
+
+class JournalEntryRequest(BaseModel):
+ entry_date: date
+ description: str
+ lines: List[JournalLine]
+
+
+@router.post("")
def create_journal_entry(req: JournalEntryRequest):
-
- if len(req.lines) < 2:
- 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:
+ with get_connection() as conn:
+ with conn.cursor() as cur:
+ # 仕訳ヘッダ
cur.execute("""
- INSERT INTO journal_lines
- (journal_entry_id, account_id, debit, credit)
- VALUES (%s, %s, %s, %s)
- """, (
- journal_entry_id,
- line.account_id,
- line.debit,
- line.credit
- ))
+ INSERT INTO journal_entries (entry_date, description)
+ VALUES (%s, %s)
+ RETURNING id
+ """, (req.entry_date, req.description))
+ entry_id = cur.fetchone()[0]
- return {
- "message": "仕訳を登録しました",
- "journal_entry_id": journal_entry_id
- }
+ # 明細
+ for line in req.lines:
+ 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()
-# -------------------------------------------------
-# 仕訳一覧取得
-# -------------------------------------------------
-@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
- }
+ return {"status": "ok", "journal_entry_id": entry_id}
diff --git a/frontend/journal-edit.html b/frontend/journal-edit.html
new file mode 100644
index 0000000..64e7970
--- /dev/null
+++ b/frontend/journal-edit.html
@@ -0,0 +1,196 @@
+
+
+
+
+修正仕訳入力
+
+
+
+
+修正仕訳入力
+
+
+
+
+
+
+
+
+
+
+ | 科目 |
+ 借方 |
+ 貸方 |
+ 操作 |
+
+
+
+
+
+ | 合計 |
+ 0 |
+ 0 |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/frontend/journal-entry.html b/frontend/journal-entry.html
index c56a8c0..477985f 100644
--- a/frontend/journal-entry.html
+++ b/frontend/journal-entry.html
@@ -1,54 +1,41 @@
-
- 仕訳入力(複数行・消費税自動)
-
+
+仕訳入力
+
-
-
-
-
-
-
+仕訳入力
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
@@ -63,261 +50,210 @@
-
-
- | 合計 |
- 0 |
- 0 |
- |
-
-
-
-
-
-
+
+
+
+
diff --git a/frontend/journal-list.html b/frontend/journal-list.html
index 4898b58..f55affd 100644
--- a/frontend/journal-list.html
+++ b/frontend/journal-list.html
@@ -28,7 +28,7 @@
日付 |
摘要 |
借方合計 |
- 贷方合计 |
+ 貸方合計 |
操作 |
@@ -63,7 +63,7 @@ async function search() {
${e.credit_total.toLocaleString()} |
-
+
|
`;
tbody.appendChild(tr);
@@ -74,23 +74,34 @@ function view(id) {
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`, {
method: "POST"
});
const data = await res.json();
-
if (!res.ok) {
alert(data.detail || "逆仕訳の作成に失敗しました");
return;
}
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");
}
+
diff --git a/frontend/js/journal-entry.js b/frontend/js/journal-entry.js
new file mode 100644
index 0000000..5bbdee2
--- /dev/null
+++ b/frontend/js/journal-entry.js
@@ -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";
+});