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 ad4ca54..1a34c78 100644
Binary files a/backend/requirements.txt and b/backend/requirements.txt differ
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 @@
+
+
+
+
+ 仕訳入力(複数行・消費税自動)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | 科目 |
+ 借方 |
+ 貸方 |
+ 税区分 |
+ 税方向 |
+ 操作 |
+
+
+
+
+
+ | 合計 |
+ 0 |
+ 0 |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+仕訳一覧
+
+
+
+
+仕訳一覧
+
+
+ ~
+
+
+
+
+
+
+
+
+
+ | 日付 |
+ 摘要 |
+ 借方合計 |
+ 贷方合计 |
+ 操作 |
+
+
+
+
+
+
+
+
+