Files
njts-accounting-core/backend/app/modules/opening_balances/router.py
2026-02-21 10:51:25 +09:00

231 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}