112 lines
4.3 KiB
Python
112 lines
4.3 KiB
Python
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
|
||
|
||
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="金額の指定が不正です。")
|