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 @@ + + + + +修正仕訳入力 + + + + +

修正仕訳入力

+ + + + + + + + + + + + + + + + + + + + + + + + + +
科目借方貸方操作
合計00
+ + + + +
+ + + + + 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 @@ - - - - - - - -
合計00
- - - -
+
+ + +
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"; +});