From b6751ced1852e5c60a1aacf61e3258f56ba3210e Mon Sep 17 00:00:00 2001 From: "FNOS-WIN11ENT\\choshoukaku" Date: Mon, 15 Dec 2025 14:49:59 +0900 Subject: [PATCH] =?UTF-8?q?20251215=E6=BB=B4=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/main.py | 3 +- backend/app/modules/journal_entry.py | 16 + .../app/modules/opening_balances/router.py | 184 +++++++--- backend/app/routers/__init__.py | 0 backend/app/routers/accounts.py | 0 backend/app/routers/journal_entries.py | 232 +++++++++++++ backend/app/routers/opening_balances.py | 0 backend/requirements.txt | Bin 43 -> 686 bytes backend/sql/001_accounts_bs_master.sql | 41 +++ frontend/journal-entry.html | 324 ++++++++++++++++++ frontend/journal-list.html | 97 ++++++ 11 files changed, 851 insertions(+), 46 deletions(-) create mode 100644 backend/app/modules/journal_entry.py create mode 100644 backend/app/routers/__init__.py create mode 100644 backend/app/routers/accounts.py create mode 100644 backend/app/routers/journal_entries.py create mode 100644 backend/app/routers/opening_balances.py create mode 100644 backend/sql/001_accounts_bs_master.sql create mode 100644 frontend/journal-entry.html create mode 100644 frontend/journal-list.html diff --git a/backend/app/main.py b/backend/app/main.py index 6ecace0..a782fdb 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -61,7 +61,8 @@ app.include_router(general_ledger_router) from app.modules.opening_balances.lock_router import router as opening_balance_lock_router app.include_router(opening_balance_lock_router) - +from app.routers import journal_entries +app.include_router(journal_entries.router) diff --git a/backend/app/modules/journal_entry.py b/backend/app/modules/journal_entry.py new file mode 100644 index 0000000..b740a66 --- /dev/null +++ b/backend/app/modules/journal_entry.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel +from typing import List +from decimal import Decimal +from datetime import date + + +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] diff --git a/backend/app/modules/opening_balances/router.py b/backend/app/modules/opening_balances/router.py index 1103c29..303ae6a 100644 --- a/backend/app/modules/opening_balances/router.py +++ b/backend/app/modules/opening_balances/router.py @@ -1,7 +1,7 @@ from fastapi import APIRouter, HTTPException, Query -from pydantic import BaseModel -from typing import List -from decimal import Decimal +from pydantic import BaseModel, field_validator +from typing import List, Optional, Tuple +from decimal import Decimal, InvalidOperation from app.core.database import get_connection @@ -15,13 +15,33 @@ class OpeningBalanceItem(BaseModel): debit: Decimal = Decimal("0") credit: Decimal = Decimal("0") + @field_validator("debit", "credit", mode="before") + @classmethod + def to_decimal(cls, v): + try: + d = Decimal(str(v)) + except InvalidOperation: + raise ValueError("金額は数値で入力してください") + if d < 0: + raise ValueError("金額は0以上で入力してください") + return d + + @field_validator("credit") + @classmethod + def exclusive_debit_credit(cls, v, info): + # 同時に > 0 は不可(0/0 は許容:行スキップ用) + debit = info.data.get("debit", Decimal("0")) + if v > 0 and Decimal(debit) > 0: + raise ValueError("借方と貸方を同時に入力しないでください") + return v + class OpeningBalanceRequest(BaseModel): fiscal_year: int balances: List[OpeningBalanceItem] -# ---------- GET:期首残高取得 ---------- +# ---------- GET:期首残高取得(BS のみ) ---------- @router.get("", summary="期首残高取得") def get_opening_balances( @@ -41,68 +61,142 @@ def get_opening_balances( ON o.account_id = a.account_id AND o.fiscal_year = %s WHERE a.is_active = true + AND a.account_type IN ('asset','liability','equity') ORDER BY a.account_code """, (fiscal_year,)) return cur.fetchall() -# ---------- POST:期首残高保存 ---------- +# ---------- 内部 util:繰越利益(剰余金)アカウント検索 ---------- + +def _find_retained_earnings(cur) -> Optional[Tuple[int, str]]: + """ + 可能な名称で繰越利益(剰余金)科目を探す。 + 優先:'繰越利益剰余金' → '繰越利益' + 戻り値: (account_id, account_name) or None + """ + cur.execute(""" + SELECT account_id, account_name + FROM accounts + WHERE is_active = true + AND account_type = 'equity' + AND account_name IN ('繰越利益剰余金','繰越利益') + ORDER BY CASE account_name + WHEN '繰越利益剰余金' THEN 1 + WHEN '繰越利益' THEN 2 + ELSE 99 + END + LIMIT 1 + """) + row = cur.fetchone() + return (row["account_id"], row["account_name"]) if row else None + + +# ---------- POST:期首残高保存(BSのみ・繰越利益は自動計算) ---------- @router.post("", summary="期首残高保存") def save_opening_balances(req: OpeningBalanceRequest): - # 年度锁定チェック - cur.execute(""" - SELECT is_locked - FROM fiscal_year_locks - WHERE fiscal_year = %s - """, (req.fiscal_year,)) - - row = cur.fetchone() - if row and row["is_locked"]: - raise HTTPException( - status_code=400, - detail="この会計年度の期首残高は既に確定されています。" - ) - - total_debit = Decimal("0") - total_credit = Decimal("0") - - for b in req.balances: - total_debit += b.debit - total_credit += b.credit - - if total_debit != total_credit: - raise HTTPException( - status_code=400, - detail="借方合計と貸方合計が一致していません。" - ) - with get_connection() as conn, conn.cursor() as cur: - - # 既存データ削除(年度単位) + # 年度ロック cur.execute(""" - DELETE FROM opening_balances + SELECT is_locked + FROM fiscal_year_locks WHERE fiscal_year = %s """, (req.fiscal_year,)) + row = cur.fetchone() + if row and row["is_locked"]: + raise HTTPException(status_code=400, detail="この会計年度の期首残高は既に確定されています。") + + # 全 BS 科目取得(存在性/種別チェック用) + cur.execute(""" + SELECT account_id, account_type, account_name + FROM accounts + WHERE is_active = true + """) + acc_map = {r["account_id"]: (r["account_type"], r["account_name"]) for r in cur.fetchall()} + + # 入力の整形:BSのみ採用・繰越利益は無視(後で自動作成) + bs_items: List[OpeningBalanceItem] = [] + total_debit = Decimal("0") + total_credit = Decimal("0") - # 再登録 for b in req.balances: + # 科目存在 + if b.account_id not in acc_map: + raise HTTPException(status_code=400, detail=f"存在しない科目IDです:{b.account_id}") + + acc_type, acc_name = acc_map[b.account_id] + + # 繰越利益(剰余金)は前端から送られても無視(自動計算対象) + if acc_name in ("繰越利益剰余金", "繰越利益"): + continue + + # BS 以外は不可 + if acc_type not in ("asset", "liability", "equity"): + raise HTTPException(status_code=400, detail=f"BS科目のみ設定可能です({acc_name})") + + # 0/0 はスキップ if b.debit == 0 and b.credit == 0: continue + # 集計(繰越利益を除く) + total_debit += b.debit + total_credit += b.credit + bs_items.append(b) + + # 繰越利益(剰余金)を自動計算 + diff = total_debit - total_credit # 借方合計 − 貸方合計 + re_account = _find_retained_earnings(cur) + + # 既存データ削除(年度単位) + cur.execute("DELETE FROM opening_balances WHERE fiscal_year = %s", (req.fiscal_year,)) + + # 再登録:BS各行 + for b in bs_items: cur.execute(""" INSERT INTO opening_balances (fiscal_year, account_id, opening_debit, opening_credit) - VALUES - (%s, %s, %s, %s) - """, ( - req.fiscal_year, - b.account_id, - b.debit, - b.credit - )) + VALUES (%s, %s, %s, %s) + """, (req.fiscal_year, b.account_id, b.debit, b.credit)) + + # 自動:繰越利益(剰余金) + re_inserted = None + if re_account: + re_id, re_name = re_account + if diff > 0: + # 借方が大 → その差額を「繰越利益(剰余金)の貸方」 + cur.execute(""" + INSERT INTO opening_balances (fiscal_year, account_id, opening_debit, opening_credit) + VALUES (%s, %s, %s, %s) + """, (req.fiscal_year, re_id, Decimal("0"), diff)) + re_inserted = {"account_id": re_id, "name": re_name, "debit": "0", "credit": str(diff)} + elif diff < 0: + # 贷方が大 → その差額を「繰越利益(剰余金)の借方」 + cur.execute(""" + INSERT INTO opening_balances (fiscal_year, account_id, opening_debit, opening_credit) + VALUES (%s, %s, %s, %s) + """, (req.fiscal_year, re_id, -diff, Decimal("0"))) + re_inserted = {"account_id": re_id, "name": re_name, "debit": str(-diff), "credit": "0"} + else: + # 完全一致 → 追加不要 + pass + else: + # 繰越利益科目が登録されていない場合:差額が 0 でないなら警告 + if diff != 0: + raise HTTPException( + status_code=400, + detail="差額がありますが『繰越利益(剰余金)』科目が見つかりません。先に科目マスタを登録してください。" + ) conn.commit() - return {"message": "期首残高を保存しました。"} + return { + "message": "期首残高を保存しました。", + "totals": { + "debit": str(total_debit), + "credit": str(total_credit), + "diff_before_retained": str(diff) + }, + "retained_earnings_auto": re_inserted + } diff --git a/backend/app/routers/__init__.py b/backend/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/accounts.py b/backend/app/routers/accounts.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/routers/journal_entries.py b/backend/app/routers/journal_entries.py new file mode 100644 index 0000000..826056e --- /dev/null +++ b/backend/app/routers/journal_entries.py @@ -0,0 +1,232 @@ +from fastapi import APIRouter, HTTPException, Query +from typing import Optional +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="仕訳登録") +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: + 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 + )) + + return { + "message": "仕訳を登録しました", + "journal_entry_id": journal_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 + } diff --git a/backend/app/routers/opening_balances.py b/backend/app/routers/opening_balances.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements.txt b/backend/requirements.txt index ad4ca5437f7d41894b282279f25227a0a22540b8..1a34c782624dcae7065d78f56a64681cfc3bbdce 100644 GIT binary patch literal 686 zcmZuv?QX&_47}fy_9&p1wrqd)EC>PvG*+rMAj-o7cXk8pLy;9JzB}9JTrYFfJbOgU z87nL?V(by{!INQZQQ{RZMEu1@^k~TI?)W$3Bz%u8)udJOJ;&sp4cIXk@MYO|z88iN zGmx`mw5<8fNWDm7YQ22&wX?1ps-(R1h4Fqjtd~FKrp#o<-g3rz(Dkahy*JeCt$KsC zF_l$J{>hK9U~TENWT}VW=$>`tTC_=p(ERQ~H7NeX%&dA#M-#dvevAh?Vnb)@VNcpw zjiaH3bA5T}?zHrA#rnb}m#Sf}LizqQ#hQw`JKJZlo>eEYPR947mU~mJNFV diff --git a/backend/sql/001_accounts_bs_master.sql b/backend/sql/001_accounts_bs_master.sql new file mode 100644 index 0000000..a413e4a --- /dev/null +++ b/backend/sql/001_accounts_bs_master.sql @@ -0,0 +1,41 @@ +-- ========================= +-- BS 科目マスタ(3桁コード) +-- ========================= + +-- 資産 +INSERT INTO accounts (account_code, account_name, account_type) VALUES +('101', '現金', 'asset'), +('102', '普通預金', 'asset'), +('103', '当座預金', 'asset'), +('104', '定期預金', 'asset'), +('201', '売掛金', 'asset'), +('202', '未収入金', 'asset'), +('203', '仮払金', 'asset'), +('204', '前払費用', 'asset'), +('205', '立替金', 'asset'), +('301', '有価証券', 'asset'), +('401', '建物', 'asset'), +('402', '建物附属設備', 'asset'), +('403', '構築物', 'asset'), +('404', '機械装置', 'asset'), +('405', '車両運搬具', 'asset'), +('406', '工具器具備品', 'asset'), +('407', '土地', 'asset'), +('408', 'ソフトウェア', 'asset'); + +-- 負債 +INSERT INTO accounts (account_code, account_name, account_type) VALUES +('501', '買掛金', 'liability'), +('502', '未払金', 'liability'), +('503', '未払費用', 'liability'), +('504', '未払法人税等', 'liability'), +('505', '未払消費税等', 'liability'), +('506', '預り金', 'liability'), +('507', '前受金', 'liability'), +('508', '短期借入金', 'liability'), +('509', '長期借入金', 'liability'); + +-- 純資産 +INSERT INTO accounts (account_code, account_name, account_type) VALUES +('601', '資本金', 'equity'), +('602', '繰越利益剰余金', 'equity'); diff --git a/frontend/journal-entry.html b/frontend/journal-entry.html new file mode 100644 index 0000000..c56a8c0 --- /dev/null +++ b/frontend/journal-entry.html @@ -0,0 +1,324 @@ + + + + + 仕訳入力(複数行・消費税自動) + + + + +

+
+ + +
+

+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + + + + + + + + + + + + + + + + + + + + +
科目借方貸方税区分税方向操作
合計00
+ + + + +
+ + + + + diff --git a/frontend/journal-list.html b/frontend/journal-list.html new file mode 100644 index 0000000..4898b58 --- /dev/null +++ b/frontend/journal-list.html @@ -0,0 +1,97 @@ + + + + +仕訳一覧 + + + + +

仕訳一覧

+ + + + + + + + + + + + + + + + + + + + +
日付摘要借方合計贷方合计操作
+ + + + +