from fastapi import APIRouter, HTTPException from pydantic import BaseModel, field_validator from typing import List from decimal import Decimal, InvalidOperation from psycopg.errors import ForeignKeyViolation, CheckViolation from app.core.database import get_connection from app.core.fiscal_lock import check_fiscal_year_unlocked from datetime import date router = APIRouter(prefix="/journals", tags=["仕訳"]) class JournalLine(BaseModel): account_id: int debit: Decimal = Decimal("0") credit: Decimal = Decimal("0") @field_validator("debit", "credit", mode="before") @classmethod def to_decimal(cls, v): # 允许传 0 / 整数 / 字符串 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 的校验在整体里做 return v class JournalCreate(BaseModel): journal_date: str # YYYY-MM-DD description: str lines: List[JournalLine] @field_validator("journal_date") @classmethod def validate_date(cls, v: str): # 简单校验格式,严格校验可用 datetime.date.fromisoformat if len(v) != 10 or v[4] != "-" or v[7] != "-": raise ValueError("日付はYYYY-MM-DD形式で入力してください") return v @field_validator("description") @classmethod def validate_desc(cls, v: str): if not v.strip(): raise ValueError("摘要を入力してください") if len(v) > 255: raise ValueError("摘要が長すぎます(255文字以内)") return v @router.post("/", summary="仕訳登録", status_code=201) def create_journal(data: JournalCreate): # 1) 行必須 if not data.lines: raise HTTPException(status_code=400, detail="仕訳明細を1行以上入力してください。") # 2) 借貸排他 & 非ゼロ for i, l in enumerate(data.lines, start=1): if l.debit > 0 and l.credit > 0: raise HTTPException(status_code=400, detail=f"{i}行目:借方と貸方を同時に入力しないでください。") if l.debit == 0 and l.credit == 0: raise HTTPException(status_code=400, detail=f"{i}行目:金額が未入力です(借方または貸方のどちらかに金額を入力)。") # 3) 借貸一致 debit_total = sum(l.debit for l in data.lines) credit_total = sum(l.credit for l in data.lines) if debit_total != credit_total: raise HTTPException(status_code=400, detail="借方合計と貸方合計が一致しません。") # 4) 仕訳に使う科目が全て存在するか事前チェック account_ids = sorted({l.account_id for l in data.lines}) with get_connection() as conn, conn.cursor() as cur: cur.execute( "SELECT account_id FROM accounts WHERE account_id = ANY(%s)", (account_ids,) ) found = {row["account_id"] for row in cur.fetchall()} missing = [a for a in account_ids if a not in found] if missing: raise HTTPException( status_code=400, detail=f"存在しない科目IDが含まれています:{missing}" ) try: # 5) 登録(ヘッダ → 明細) cur.execute( "INSERT INTO journal_entries (journal_date, description) VALUES (%s, %s) RETURNING journal_id", (data.journal_date, data.description), ) jid = cur.fetchone()["journal_id"] for l in data.lines: cur.execute( """INSERT INTO journal_lines (journal_id, account_id, debit, credit) VALUES (%s, %s, %s, %s)""", (jid, l.account_id, l.debit, l.credit), ) conn.commit() return {"journal_id": jid} except ForeignKeyViolation: # 理论上到不了(我们前面已做存在性检查),兜底 raise HTTPException(status_code=400, detail="科目IDが不正です。") except CheckViolation: raise HTTPException(status_code=400, detail="金額の指定が不正です。") @router.put("/{journal_id}", summary="仕訳修正") def update_journal(journal_id: int, data: JournalCreate): with get_connection() as conn, conn.cursor() as cur: # ① 先取原仕訳の日付 cur.execute( "SELECT journal_date FROM journal_entries WHERE journal_id = %s", (journal_id,) ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="仕訳が存在しません。") # 🔒 ② 这里!!年度锁定检查 check_fiscal_year_unlocked(row["journal_date"]) # ③ 删除旧明细 cur.execute( "DELETE FROM journal_lines WHERE journal_id = %s", (journal_id,) ) # ④ 更新ヘッダ cur.execute( "UPDATE journal_entries SET journal_date=%s, description=%s WHERE journal_id=%s", (data.journal_date, data.description, journal_id) ) # ⑤ 插入新明细 for l in data.lines: cur.execute( """INSERT INTO journal_lines (journal_id, account_id, debit, credit) VALUES (%s, %s, %s, %s)""", (journal_id, l.account_id, l.debit, l.credit) ) conn.commit() return {"message": "仕訳を修正しました。"} @router.delete("/{journal_id}", summary="仕訳削除") def delete_journal(journal_id: int): with get_connection() as conn, conn.cursor() as cur: # ① 先取日付 cur.execute( "SELECT journal_date FROM journal_entries WHERE journal_id = %s", (journal_id,) ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="仕訳が存在しません。") # 🔒 ② 年度锁定检查(就在这里) check_fiscal_year_unlocked(row["journal_date"]) # ③ 删除 cur.execute( "DELETE FROM journal_lines WHERE journal_id = %s", (journal_id,) ) cur.execute( "DELETE FROM journal_entries WHERE journal_id = %s", (journal_id,) ) conn.commit() return {"message": "仕訳を削除しました。"}