Files
njts-accounting-core/backend/app/modules/journals/router.py
2025-12-14 23:54:48 +09:00

184 lines
6.6 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
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": "仕訳を削除しました。"}