20251215滴修正

This commit is contained in:
FNOS-WIN11ENT\choshoukaku
2025-12-15 14:49:59 +09:00
parent 74dcb88fe6
commit b6751ced18
11 changed files with 851 additions and 46 deletions

View File

@@ -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)

View 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]

View File

@@ -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
}

View File

View File

View 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
}

View File

Binary file not shown.

View 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');