203 lines
7.6 KiB
Python
203 lines
7.6 KiB
Python
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:
|
||
# 年度ロック
|
||
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()}
|
||
|
||
# 入力の整形: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))
|
||
|
||
# 自動:繰越利益(剰余金)
|
||
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
|
||
}
|