新規作成
This commit is contained in:
111
backend/app/modules/journals/router.py
Normal file
111
backend/app/modules/journals/router.py
Normal file
@@ -0,0 +1,111 @@
|
||||
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="金額の指定が不正です。")
|
||||
Reference in New Issue
Block a user