from fastapi import APIRouter, HTTPException, Query from pydantic import BaseModel, field_validator from typing import List, Optional, Tuple from decimal import Decimal, InvalidOperation from app.core.database import get_connection router = APIRouter(prefix="/opening-balances", tags=["期首残高"]) # ---------- Models ---------- class OpeningBalanceItem(BaseModel): account_id: int 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:期首残高取得(BS のみ) ---------- @router.get("", summary="期首残高取得") def get_opening_balances( fiscal_year: int = Query(..., description="会計年度(例:2025)") ): with get_connection() as conn, conn.cursor() as cur: cur.execute(""" SELECT a.account_id, a.account_code, a.account_name, a.account_type, COALESCE(o.opening_debit, 0) AS opening_debit, COALESCE(o.opening_credit, 0) AS opening_credit FROM accounts a LEFT JOIN opening_balances o 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() # ---------- 内部 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): with get_connection() as conn, conn.cursor() as cur: # テーブルの存在確認(例外処理を避けるため) table_exists = False try: cur.execute(""" SELECT EXISTS ( SELECT 1 FROM information_schema.tables WHERE table_name = 'fiscal_year_locks' ) """) table_exists = cur.fetchone()[0] except: pass # 年度ロック(テーブルが存在する場合のみチェック) if table_exists: 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="この会計年度の期首残高は既に確定されています。") # 全 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()} # ★ 重要:空白データ保存防止(ユーザーが読込せずに保存した場合の対策) if not req.balances: raise HTTPException( status_code=400, detail="保存するデータがありません。「読込」ボタンで期首残高を読み込んでください。" ) # 入力の整形: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) # ★ 重要:0/0の行だけで実質的にデータなし(削除防止) if not bs_items: raise HTTPException( status_code=400, detail="全科目の残高が0です。期首残高データを入力してください。" ) # 繰越利益(剰余金)を自動計算 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)) # 自動:繰越利益(剰余金) 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": "期首残高を保存しました。", "totals": { "debit": str(total_debit), "credit": str(total_credit), "diff_before_retained": str(diff) }, "retained_earnings_auto": re_inserted }