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 }