20251215滴修正
This commit is contained in:
@@ -61,7 +61,8 @@ app.include_router(general_ledger_router)
|
||||
from app.modules.opening_balances.lock_router import router as opening_balance_lock_router
|
||||
app.include_router(opening_balance_lock_router)
|
||||
|
||||
|
||||
from app.routers import journal_entries
|
||||
app.include_router(journal_entries.router)
|
||||
|
||||
|
||||
|
||||
|
||||
16
backend/app/modules/journal_entry.py
Normal file
16
backend/app/modules/journal_entry.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
|
||||
|
||||
class JournalLine(BaseModel):
|
||||
account_id: int
|
||||
debit: Decimal = Decimal("0")
|
||||
credit: Decimal = Decimal("0")
|
||||
|
||||
|
||||
class JournalEntryRequest(BaseModel):
|
||||
entry_date: date
|
||||
description: str
|
||||
lines: List[JournalLine]
|
||||
@@ -1,7 +1,7 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
from typing import List
|
||||
from decimal import Decimal
|
||||
from pydantic import BaseModel, field_validator
|
||||
from typing import List, Optional, Tuple
|
||||
from decimal import Decimal, InvalidOperation
|
||||
|
||||
from app.core.database import get_connection
|
||||
|
||||
@@ -15,13 +15,33 @@ class OpeningBalanceItem(BaseModel):
|
||||
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:期首残高取得 ----------
|
||||
# ---------- GET:期首残高取得(BS のみ) ----------
|
||||
|
||||
@router.get("", summary="期首残高取得")
|
||||
def get_opening_balances(
|
||||
@@ -41,68 +61,142 @@ def get_opening_balances(
|
||||
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()
|
||||
|
||||
|
||||
# ---------- POST:期首残高保存 ----------
|
||||
# ---------- 内部 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):
|
||||
|
||||
# 年度锁定チェック
|
||||
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="この会計年度の期首残高は既に確定されています。"
|
||||
)
|
||||
|
||||
total_debit = Decimal("0")
|
||||
total_credit = Decimal("0")
|
||||
|
||||
for b in req.balances:
|
||||
total_debit += b.debit
|
||||
total_credit += b.credit
|
||||
|
||||
if total_debit != total_credit:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="借方合計と貸方合計が一致していません。"
|
||||
)
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
|
||||
# 既存データ削除(年度単位)
|
||||
# 年度ロック
|
||||
cur.execute("""
|
||||
DELETE FROM opening_balances
|
||||
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
|
||||
))
|
||||
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": "期首残高を保存しました。"}
|
||||
return {
|
||||
"message": "期首残高を保存しました。",
|
||||
"totals": {
|
||||
"debit": str(total_debit),
|
||||
"credit": str(total_credit),
|
||||
"diff_before_retained": str(diff)
|
||||
},
|
||||
"retained_earnings_auto": re_inserted
|
||||
}
|
||||
|
||||
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/__init__.py
Normal file
0
backend/app/routers/accounts.py
Normal file
0
backend/app/routers/accounts.py
Normal file
232
backend/app/routers/journal_entries.py
Normal file
232
backend/app/routers/journal_entries.py
Normal file
@@ -0,0 +1,232 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from typing import Optional
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
|
||||
from app.core.database import get_connection
|
||||
from app.models.journal_entry import JournalEntryRequest
|
||||
|
||||
router = APIRouter(prefix="/journal-entries", tags=["仕訳"])
|
||||
|
||||
|
||||
# -------------------------------------------------
|
||||
# 仕訳登録
|
||||
# -------------------------------------------------
|
||||
@router.post("", summary="仕訳登録")
|
||||
def create_journal_entry(req: JournalEntryRequest):
|
||||
|
||||
if len(req.lines) < 2:
|
||||
raise HTTPException(status_code=400, detail="仕訳行は2行以上必要です")
|
||||
|
||||
if req.entry_date < date(2025, 6, 1):
|
||||
raise HTTPException(status_code=400, detail="仕訳日は2025-06-01以降である必要があります")
|
||||
|
||||
debit_total = Decimal("0")
|
||||
credit_total = Decimal("0")
|
||||
|
||||
for line in req.lines:
|
||||
if line.debit < 0 or line.credit < 0:
|
||||
raise HTTPException(status_code=400, detail="金額は正数で入力してください")
|
||||
|
||||
if line.debit > 0 and line.credit > 0:
|
||||
raise HTTPException(status_code=400, detail="同一行で借方・貸方の同時入力は不可です")
|
||||
|
||||
debit_total += line.debit
|
||||
credit_total += line.credit
|
||||
|
||||
if debit_total != credit_total:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"借方合計({debit_total})と貸方合計({credit_total})が一致しません"
|
||||
)
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
|
||||
# 期首ロック確認
|
||||
cur.execute("""
|
||||
SELECT is_locked
|
||||
FROM opening_balance_locks
|
||||
WHERE fiscal_year = 2025
|
||||
""")
|
||||
row = cur.fetchone()
|
||||
if not row or not row[0]:
|
||||
raise HTTPException(status_code=400, detail="期首残高が確定(lock)されていません")
|
||||
|
||||
# ヘッダ登録
|
||||
cur.execute("""
|
||||
INSERT INTO journal_entries (entry_date, description)
|
||||
VALUES (%s, %s)
|
||||
RETURNING journal_entry_id
|
||||
""", (req.entry_date, req.description))
|
||||
journal_entry_id = cur.fetchone()[0]
|
||||
|
||||
# 明細登録
|
||||
for line in req.lines:
|
||||
cur.execute("""
|
||||
INSERT INTO journal_lines
|
||||
(journal_entry_id, account_id, debit, credit)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (
|
||||
journal_entry_id,
|
||||
line.account_id,
|
||||
line.debit,
|
||||
line.credit
|
||||
))
|
||||
|
||||
return {
|
||||
"message": "仕訳を登録しました",
|
||||
"journal_entry_id": journal_entry_id
|
||||
}
|
||||
|
||||
|
||||
# -------------------------------------------------
|
||||
# 仕訳一覧取得
|
||||
# -------------------------------------------------
|
||||
@router.get("", summary="仕訳一覧取得")
|
||||
def list_journal_entries(
|
||||
from_date: Optional[str] = Query(None),
|
||||
to_date: Optional[str] = Query(None),
|
||||
keyword: Optional[str] = Query(None)
|
||||
):
|
||||
sql = """
|
||||
SELECT
|
||||
je.journal_entry_id,
|
||||
je.entry_date,
|
||||
je.description,
|
||||
SUM(jl.debit) AS debit_total,
|
||||
SUM(jl.credit) AS credit_total
|
||||
FROM journal_entries je
|
||||
JOIN journal_lines jl
|
||||
ON jl.journal_entry_id = je.journal_entry_id
|
||||
WHERE 1=1
|
||||
"""
|
||||
params = []
|
||||
|
||||
if from_date:
|
||||
sql += " AND je.entry_date >= %s"
|
||||
params.append(from_date)
|
||||
if to_date:
|
||||
sql += " AND je.entry_date <= %s"
|
||||
params.append(to_date)
|
||||
if keyword:
|
||||
sql += " AND je.description ILIKE %s"
|
||||
params.append(f"%{keyword}%")
|
||||
|
||||
sql += """
|
||||
GROUP BY je.journal_entry_id, je.entry_date, je.description
|
||||
ORDER BY je.entry_date DESC, je.journal_entry_id DESC
|
||||
"""
|
||||
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
cur.execute(sql, params)
|
||||
rows = cur.fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"journal_entry_id": r[0],
|
||||
"entry_date": r[1],
|
||||
"description": r[2],
|
||||
"debit_total": r[3],
|
||||
"credit_total": r[4],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
# -------------------------------------------------
|
||||
# 仕訳明細取得
|
||||
# -------------------------------------------------
|
||||
@router.get("/{journal_entry_id}", summary="仕訳明細取得")
|
||||
def get_journal_entry(journal_entry_id: int):
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
|
||||
cur.execute("""
|
||||
SELECT entry_date, description
|
||||
FROM journal_entries
|
||||
WHERE journal_entry_id = %s
|
||||
""", (journal_entry_id,))
|
||||
header = cur.fetchone()
|
||||
if not header:
|
||||
raise HTTPException(status_code=404, detail="仕訳が存在しません")
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
jl.account_id,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
jl.debit,
|
||||
jl.credit
|
||||
FROM journal_lines jl
|
||||
JOIN accounts a ON a.account_id = jl.account_id
|
||||
WHERE jl.journal_entry_id = %s
|
||||
""", (journal_entry_id,))
|
||||
lines = cur.fetchall()
|
||||
|
||||
return {
|
||||
"entry_date": header[0],
|
||||
"description": header[1],
|
||||
"lines": [
|
||||
{
|
||||
"account_id": l[0],
|
||||
"account_code": l[1],
|
||||
"account_name": l[2],
|
||||
"debit": l[3],
|
||||
"credit": l[4]
|
||||
} for l in lines
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# -------------------------------------------------
|
||||
# 逆仕訳生成(修正仕訳)
|
||||
# -------------------------------------------------
|
||||
@router.post("/{journal_entry_id}/reverse", summary="逆仕訳生成")
|
||||
def reverse_journal_entry(journal_entry_id: int):
|
||||
with get_connection() as conn, conn.cursor() as cur:
|
||||
|
||||
# 元仕訳取得
|
||||
cur.execute("""
|
||||
SELECT entry_date, description
|
||||
FROM journal_entries
|
||||
WHERE journal_entry_id = %s
|
||||
""", (journal_entry_id,))
|
||||
header = cur.fetchone()
|
||||
if not header:
|
||||
raise HTTPException(status_code=404, detail="仕訳が存在しません")
|
||||
|
||||
entry_date, description = header
|
||||
|
||||
cur.execute("""
|
||||
SELECT account_id, debit, credit
|
||||
FROM journal_lines
|
||||
WHERE journal_entry_id = %s
|
||||
""", (journal_entry_id,))
|
||||
lines = cur.fetchall()
|
||||
if not lines:
|
||||
raise HTTPException(status_code=400, detail="仕訳明細が存在しません")
|
||||
|
||||
# 逆仕訳ヘッダ
|
||||
cur.execute("""
|
||||
INSERT INTO journal_entries (entry_date, description)
|
||||
VALUES (%s, %s)
|
||||
RETURNING journal_entry_id
|
||||
""", (entry_date, f"【修正】{description}"))
|
||||
new_entry_id = cur.fetchone()[0]
|
||||
|
||||
# 逆仕訳明細(借贷反転)
|
||||
for account_id, debit, credit in lines:
|
||||
cur.execute("""
|
||||
INSERT INTO journal_lines
|
||||
(journal_entry_id, account_id, debit, credit)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""", (
|
||||
new_entry_id,
|
||||
account_id,
|
||||
credit,
|
||||
debit
|
||||
))
|
||||
|
||||
return {
|
||||
"message": "逆仕訳を作成しました",
|
||||
"reversed_journal_entry_id": new_entry_id
|
||||
}
|
||||
0
backend/app/routers/opening_balances.py
Normal file
0
backend/app/routers/opening_balances.py
Normal file
Binary file not shown.
41
backend/sql/001_accounts_bs_master.sql
Normal file
41
backend/sql/001_accounts_bs_master.sql
Normal file
@@ -0,0 +1,41 @@
|
||||
-- =========================
|
||||
-- BS 科目マスタ(3桁コード)
|
||||
-- =========================
|
||||
|
||||
-- 資産
|
||||
INSERT INTO accounts (account_code, account_name, account_type) VALUES
|
||||
('101', '現金', 'asset'),
|
||||
('102', '普通預金', 'asset'),
|
||||
('103', '当座預金', 'asset'),
|
||||
('104', '定期預金', 'asset'),
|
||||
('201', '売掛金', 'asset'),
|
||||
('202', '未収入金', 'asset'),
|
||||
('203', '仮払金', 'asset'),
|
||||
('204', '前払費用', 'asset'),
|
||||
('205', '立替金', 'asset'),
|
||||
('301', '有価証券', 'asset'),
|
||||
('401', '建物', 'asset'),
|
||||
('402', '建物附属設備', 'asset'),
|
||||
('403', '構築物', 'asset'),
|
||||
('404', '機械装置', 'asset'),
|
||||
('405', '車両運搬具', 'asset'),
|
||||
('406', '工具器具備品', 'asset'),
|
||||
('407', '土地', 'asset'),
|
||||
('408', 'ソフトウェア', 'asset');
|
||||
|
||||
-- 負債
|
||||
INSERT INTO accounts (account_code, account_name, account_type) VALUES
|
||||
('501', '買掛金', 'liability'),
|
||||
('502', '未払金', 'liability'),
|
||||
('503', '未払費用', 'liability'),
|
||||
('504', '未払法人税等', 'liability'),
|
||||
('505', '未払消費税等', 'liability'),
|
||||
('506', '預り金', 'liability'),
|
||||
('507', '前受金', 'liability'),
|
||||
('508', '短期借入金', 'liability'),
|
||||
('509', '長期借入金', 'liability');
|
||||
|
||||
-- 純資産
|
||||
INSERT INTO accounts (account_code, account_name, account_type) VALUES
|
||||
('601', '資本金', 'equity'),
|
||||
('602', '繰越利益剰余金', 'equity');
|
||||
Reference in New Issue
Block a user