Files
njts-accounting-core/backend/app/routers/journal_entries.py
2026-02-20 15:47:27 +09:00

689 lines
26 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 app.core.database import get_connection
from pydantic import BaseModel
from typing import List, Optional
from decimal import Decimal
from datetime import date
router = APIRouter(prefix="/journal-entries", tags=["仕訳"])
class JournalLine(BaseModel):
account_id: int
debit: Decimal = Decimal("0")
credit: Decimal = Decimal("0")
tax_rate: Optional[int] = None # 10 or 8 or None
tax_direction: Optional[str] = None # "paid" or "received" or None
class JournalEntryRequest(BaseModel):
entry_date: date
description: str
lines: List[JournalLine]
tax_paid_account_id: Optional[int] = None # 仮払消費税等 勘定
tax_received_account_id: Optional[int] = None # 仮受消費税等 勘定
rounding_mode: str = "round" # "round", "floor", or "ceil"
original_entry_id: Optional[int] = None # 修正元の仕訳ID新版本追踪システム用
parent_entry_id: Optional[int] = None # 互換性のため残す(非推奨)
class JournalEntryUpdateRequest(BaseModel):
description: str
lines: List[JournalLine]
updated_reason: str
class JournalEntryDeleteRequest(BaseModel):
deleted_reason: str
@router.get("", summary="仕訳一覧取得")
def get_journal_entries(
from_date: date = None,
to_date: date = None,
keyword: str = None,
account_id: int = None,
account_side: str = None, # "debit" or "credit"
include_history: bool = False
):
"""
仕訳一覧を検索して取得
- include_history=true: すべての版本を表示(修正歴含む)
- include_history=false (デフォルト): 最新版本のみを表示
"""
with get_connection() as conn:
with conn.cursor() as cur:
# 首先检查字段是否存在
cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'journal_entries'
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
""")
existing_fields = {row['column_name'] for row in cur.fetchall()}
# 根据字段是否存在构建不同的查询
if 'revision_count' in existing_fields and 'is_latest' in existing_fields:
# 使用新字段的完整查询
if include_history:
sql = """
SELECT
je.journal_entry_id,
je.entry_date,
je.description,
je.fiscal_year,
je.revision_count,
je.is_latest,
je.original_entry_id,
COALESCE(SUM(jl.debit), 0) as debit_total,
COALESCE(SUM(jl.credit), 0) as credit_total,
STRING_AGG(DISTINCT CASE WHEN jl.tax_rate IS NOT NULL THEN jl.tax_rate::text END, ', ' ORDER BY CASE WHEN jl.tax_rate IS NOT NULL THEN jl.tax_rate::text END) as tax_rates
FROM journal_entries je
LEFT JOIN journal_lines jl ON je.journal_entry_id = jl.journal_entry_id
WHERE je.is_deleted = false
"""
else:
sql = """
SELECT
je.journal_entry_id,
je.entry_date,
je.description,
je.fiscal_year,
je.revision_count,
je.is_latest,
je.original_entry_id,
COALESCE(SUM(jl.debit), 0) as debit_total,
COALESCE(SUM(jl.credit), 0) as credit_total,
STRING_AGG(DISTINCT CASE WHEN jl.tax_rate IS NOT NULL THEN jl.tax_rate::text END, ', ' ORDER BY CASE WHEN jl.tax_rate IS NOT NULL THEN jl.tax_rate::text END) as tax_rates
FROM journal_entries je
LEFT JOIN journal_lines jl ON je.journal_entry_id = jl.journal_entry_id
WHERE je.is_deleted = false
AND je.is_latest = true
"""
params = []
else:
# 使用不含新字段的查询(向后兼容)
sql = """
SELECT
je.journal_entry_id,
je.entry_date,
je.description,
je.fiscal_year,
1 as revision_count,
true as is_latest,
NULL::INTEGER as original_entry_id,
COALESCE(SUM(jl.debit), 0) as debit_total,
COALESCE(SUM(jl.credit), 0) as credit_total,
STRING_AGG(DISTINCT CASE WHEN jl.tax_rate IS NOT NULL THEN jl.tax_rate::text END, ', ' ORDER BY CASE WHEN jl.tax_rate IS NOT NULL THEN jl.tax_rate::text END) as tax_rates
FROM journal_entries je
LEFT JOIN journal_lines jl ON je.journal_entry_id = jl.journal_entry_id
WHERE je.is_deleted = false
"""
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 LIKE %s"
params.append(f"%{keyword}%")
# 科目と方向で絞込SQ副查询で指定の科目・方向を持つ仕訳のみ
if account_id and account_side:
# 科目+方向の組み合わせで絞込
if account_side == "debit":
sql += """ AND je.journal_entry_id IN (
SELECT DISTINCT journal_entry_id
FROM journal_lines
WHERE account_id = %s AND debit > 0
)"""
elif account_side == "credit":
sql += """ AND je.journal_entry_id IN (
SELECT DISTINCT journal_entry_id
FROM journal_lines
WHERE account_id = %s AND credit > 0
)"""
params.append(account_id)
elif account_id:
# 方向指定がない場合は科目のみで絞込
sql += """ AND je.journal_entry_id IN (
SELECT DISTINCT journal_entry_id
FROM journal_lines
WHERE account_id = %s
)"""
params.append(account_id)
sql += """
GROUP BY je.journal_entry_id, je.entry_date, je.description, je.fiscal_year """
# 只有当字段存在时才添加到 GROUP BY
if 'revision_count' in existing_fields:
sql += ", je.revision_count"
if 'is_latest' in existing_fields:
sql += ", je.is_latest"
if 'original_entry_id' in existing_fields:
sql += ", je.original_entry_id"
sql += """
ORDER BY je.entry_date DESC, je.journal_entry_id DESC
"""
cur.execute(sql, params)
rows = cur.fetchall()
return [dict(row) for row in rows]
@router.get("/{journal_id}", summary="仕訳詳細取得")
def get_journal_entry(journal_id: int):
"""
指定された仕訳の詳細を取得
"""
with get_connection() as conn:
with conn.cursor() as cur:
# ヘッダー取得
cur.execute("""
SELECT
journal_entry_id,
entry_date,
description,
fiscal_year,
revision_count,
is_latest,
original_entry_id,
is_deleted
FROM journal_entries
WHERE journal_entry_id = %s
""", (journal_id,))
header = cur.fetchone()
if not header:
raise HTTPException(status_code=404, detail="仕訳が存在しません")
# 明細取得
cur.execute("""
SELECT
jl.journal_line_id,
jl.account_id,
a.account_code,
a.account_name,
jl.debit,
jl.credit,
jl.tax_rate,
jl.tax_direction
FROM journal_lines jl
JOIN accounts a ON jl.account_id = a.account_id
WHERE jl.journal_entry_id = %s
ORDER BY jl.journal_line_id
""", (journal_id,))
lines = cur.fetchall()
return {
**dict(header),
"lines": [dict(line) for line in lines]
}
@router.get("/{journal_id}/revision-history", summary="修正歴取得")
def get_revision_history(journal_id: int):
"""
指定された仕訳の修正歴史を取得(すべてのバージョン)
"""
with get_connection() as conn:
with conn.cursor() as cur:
# 最初に、指定されたIDが実際の元の交易か、修正版かを確認
cur.execute("""
SELECT original_entry_id FROM journal_entries WHERE journal_entry_id = %s
""", (journal_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="仕訳が存在しません")
# 元の交易IDを取得
original_id = row["original_entry_id"] if row["original_entry_id"] else journal_id
# その交易とすべての修正版を取得
cur.execute("""
SELECT
journal_entry_id,
entry_date,
description,
revision_count,
is_latest,
created_at,
created_by,
COALESCE(SUM(jl.debit), 0) as debit_total,
COALESCE(SUM(jl.credit), 0) as credit_total
FROM journal_entries je
LEFT JOIN journal_lines jl ON je.journal_entry_id = jl.journal_entry_id
WHERE je.journal_entry_id = %s
OR (je.original_entry_id = %s)
GROUP BY je.journal_entry_id, je.entry_date, je.description, je.revision_count, je.is_latest, je.created_at, je.created_by
ORDER BY je.revision_count ASC
""", (journal_id, original_id))
versions = cur.fetchall()
return {
"original_entry_id": original_id,
"total_versions": len(versions),
"versions": [dict(v) for v in versions]
}
@router.post("/{journal_id}/reverse", summary="逆仕訳作成")
def create_reverse_entry(journal_id: int):
"""
指定された仕訳の逆仕訳を作成
"""
with get_connection() as conn:
with conn.cursor() as cur:
# 元仕訳取得
cur.execute("""
SELECT entry_date, description, fiscal_year
FROM journal_entries
WHERE journal_entry_id = %s AND is_deleted = false
""", (journal_id,))
header = cur.fetchone()
if not header:
raise HTTPException(status_code=404, detail="仕訳が存在しません")
# 元仕訳の明細取得
cur.execute("""
SELECT account_id, debit, credit
FROM journal_lines
WHERE journal_entry_id = %s
""", (journal_id,))
lines = cur.fetchall()
# 逆仕訳ヘッダー作成
cur.execute("""
INSERT INTO journal_entries (
entry_date,
description,
fiscal_year,
version,
is_deleted,
created_by,
parent_entry_id
)
VALUES (%s, %s, %s, 1, false, %s, %s)
RETURNING journal_entry_id
""", (
header["entry_date"],
f"[逆仕訳] {header['description']}",
header["fiscal_year"],
"system",
journal_id # 逆仕訳の元となる仕訳ID
))
new_entry_id = cur.fetchone()["journal_entry_id"]
# 逆仕訳明細作成(借方と貸方を入れ替え)
for line in lines:
cur.execute("""
INSERT INTO journal_lines (
journal_entry_id,
account_id,
debit,
credit
)
VALUES (%s, %s, %s, %s)
""", (
new_entry_id,
line["account_id"],
line["credit"], # 借方と貸方を入れ替え
line["debit"]
))
conn.commit()
return {
"status": "ok",
"reversed_journal_entry_id": new_entry_id,
"original_journal_entry_id": journal_id
}
@router.post("", summary="仕訳登録")
def create_journal_entry(req: JournalEntryRequest):
from app.utils.month_lock import is_month_locked
from decimal import Decimal, ROUND_HALF_UP, ROUND_DOWN, ROUND_UP
if is_month_locked(req.entry_date):
raise HTTPException(
status_code=400,
detail="该月份已锁定,不能新增仕訳"
)
if not req.lines:
raise HTTPException(status_code=400, detail="仕訳明細不能为空")
# 互換性処理:古い parent_entry_id が提供された場合、original_entry_id として処理
original_entry_id = req.original_entry_id or req.parent_entry_id
# 税額計算と税行生成
all_lines = []
tax_aggregation = {} # {"paid": Decimal, "received": Decimal}
# 端数処理モード設定
rounding_map = {
"round": ROUND_HALF_UP,
"floor": ROUND_DOWN,
"ceil": ROUND_UP
}
rounding = rounding_map.get(req.rounding_mode, ROUND_HALF_UP)
for line in req.lines:
all_lines.append(line)
# 税対象行の場合、税額を計算
if line.tax_rate and line.tax_direction:
amount = line.debit if line.debit > 0 else line.credit
rate = Decimal(str(line.tax_rate)) / Decimal("100") # 10% -> 0.10
# 入力額は税抜額として、税額 = 税抜額 × 税率
raw_tax = amount * rate
tax_amount = raw_tax.quantize(Decimal("1"), rounding=rounding)
# 税方向ごとに集計
if line.tax_direction not in tax_aggregation:
tax_aggregation[line.tax_direction] = Decimal("0")
tax_aggregation[line.tax_direction] += tax_amount
# 集計した税額で税行を追加
if "paid" in tax_aggregation and tax_aggregation["paid"] > 0:
if not req.tax_paid_account_id:
raise HTTPException(status_code=400, detail="仮払消費税等 勘定を選択してください")
all_lines.append(JournalLine(
account_id=req.tax_paid_account_id,
debit=tax_aggregation["paid"],
credit=Decimal("0")
))
if "received" in tax_aggregation and tax_aggregation["received"] > 0:
if not req.tax_received_account_id:
raise HTTPException(status_code=400, detail="仮受消費税等 勘定を選択してください")
all_lines.append(JournalLine(
account_id=req.tax_received_account_id,
debit=Decimal("0"),
credit=tax_aggregation["received"]
))
# 借貸平衡校験
total_debit = sum(l.debit for l in all_lines)
total_credit = sum(l.credit for l in all_lines)
if total_debit != total_credit:
raise HTTPException(status_code=400, detail="借方和贷方不平衡")
fiscal_year = req.entry_date.year
with get_connection() as conn:
# 检查版本追踪字段是否存在
with conn.cursor() as check_cur:
check_cur.execute("""
SELECT column_name FROM information_schema.columns
WHERE table_name = 'journal_entries'
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
""")
existing_fields = {row['column_name'] for row in check_cur.fetchall()}
# 如果版本追踪字段存在,使用新系统;否则使用旧系统
if len(existing_fields) == 3: # 3个字段都存在
# 新系统:使用版本追踪 - 内联实现
with conn.cursor() as cur:
# ① 如果是修正交易,獲取最新版本的 revision_count
revision_count = 1
if original_entry_id:
cur.execute("""
SELECT revision_count FROM journal_entries
WHERE (journal_entry_id = %s OR original_entry_id = %s)
AND is_latest = true
ORDER BY created_at DESC
LIMIT 1
""", (original_entry_id, original_entry_id))
row = cur.fetchone()
if row:
revision_count = row['revision_count'] + 1
else:
revision_count = 2
# ② 如果是修正,標記舊版本
if original_entry_id:
cur.execute("""
UPDATE journal_entries
SET is_latest = false
WHERE (journal_entry_id = %s OR original_entry_id = %s)
AND is_latest = true
""", (original_entry_id, original_entry_id))
# ③ 創建新版本
cur.execute("""
INSERT INTO journal_entries (
entry_date,
description,
fiscal_year,
is_latest,
revision_count,
original_entry_id,
is_deleted,
created_by
)
VALUES (%s, %s, %s, true, %s, %s, false, %s)
RETURNING journal_entry_id
""", (
req.entry_date,
req.description,
fiscal_year,
revision_count,
original_entry_id if original_entry_id else None,
"system"
))
entry_id = cur.fetchone()["journal_entry_id"]
# ④ 插入交易明細
for line in all_lines:
# 轉換 tax_direction
tax_dir_db = None
if line.tax_direction:
if line.tax_direction == 'paid':
tax_dir_db = 'INPUT'
elif line.tax_direction == 'received':
tax_dir_db = 'OUTPUT'
else:
tax_dir_db = line.tax_direction.upper()
cur.execute("""
INSERT INTO journal_lines (
journal_entry_id,
account_id,
debit,
credit,
tax_rate,
tax_direction
)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
entry_id,
line.account_id,
line.debit,
line.credit,
line.tax_rate,
tax_dir_db
))
conn.commit()
return {
"status": "ok",
"journal_entry_id": entry_id,
"revision_created": original_entry_id is not None
}
else:
# 旧系统:没有版本追踪,直接创建仕訳
with conn.cursor() as cur:
cur.execute("""
INSERT INTO journal_entries (
entry_date, description, fiscal_year, is_deleted, created_by
) VALUES (%s, %s, %s, false, %s)
RETURNING journal_entry_id
""", (req.entry_date, req.description, fiscal_year, "system"))
entry_id = cur.fetchone()["journal_entry_id"]
# 插入交易明細
for line in all_lines:
# 轉換 tax_direction
tax_dir_db = None
if line.tax_direction:
if line.tax_direction == 'paid':
tax_dir_db = 'INPUT'
elif line.tax_direction == 'received':
tax_dir_db = 'OUTPUT'
else:
tax_dir_db = line.tax_direction.upper()
cur.execute("""
INSERT INTO journal_lines (
journal_entry_id, account_id, debit, credit,
tax_rate, tax_direction
) VALUES (%s, %s, %s, %s, %s, %s)
""", (
entry_id,
line.account_id,
line.debit,
line.credit,
line.tax_rate,
tax_dir_db
))
conn.commit()
return {
"status": "ok",
"journal_entry_id": entry_id,
"revision_created": False,
"warning": "版本追踪字段不存在,使用降级模式"
}
@router.put("/{journal_id}")
def update_journal_entry(journal_id: int, req: JournalEntryUpdateRequest):
from app.utils.month_lock import is_month_locked
with get_connection() as conn:
with conn.cursor() as cur:
# ① 取得现有仕訳
cur.execute("""
SELECT journal_date, is_deleted, version
FROM journal_entries
WHERE journal_id = %s
""", (journal_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="仕訳が存在しません")
if row["is_deleted"]:
raise HTTPException(status_code=400, detail="削除済み仕訳は更新できません")
journal_date = row["journal_date"]
# 借贷平衡校验update
total_debit = sum(l.debit for l in req.lines)
total_credit = sum(l.credit for l in req.lines)
if total_debit != total_credit:
raise HTTPException(status_code=400, detail="借方和贷方不平衡")
# ② 月次锁定检查
if is_month_locked(journal_date):
raise HTTPException(status_code=400, detail="该月份已锁定,不能修改仕訳")
# ③ 更新 header
cur.execute("""
UPDATE journal_entries
SET description = %s,
version = version + 1,
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Tokyo',
updated_reason = %s
WHERE journal_id = %s
""", (
req.description,
req.updated_reason,
journal_id
))
# ④ 删除原有明细物理删除OK
cur.execute("""
DELETE FROM journal_lines
WHERE journal_id = %s
""", (journal_id,))
# ⑤ 插入新明细
for line in req.lines:
cur.execute("""
INSERT INTO journal_lines (
journal_id,
account_id,
debit,
credit
) VALUES (%s, %s, %s, %s)
""", (
journal_id,
line.account_id,
line.debit,
line.credit
))
conn.commit()
return {
"status": "ok",
"journal_id": journal_id,
"message": "仕訳を更新しました"
}
@router.delete("/{journal_id}", summary="仕訳削除(ソフト削除)")
def delete_journal_entry(journal_id: int, req: JournalEntryDeleteRequest):
with get_connection() as conn:
with conn.cursor() as cur:
cur.execute("""
UPDATE journal_entries
SET is_deleted = true,
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Tokyo'
WHERE journal_entry_id = %s
AND is_deleted = false
RETURNING journal_entry_id
""", (journal_id,))
row = cur.fetchone()
if not row:
raise HTTPException(
status_code=404,
detail="仕訳が存在しないか、既に削除されています"
)
conn.commit()
return {
"status": "deleted",
"journal_entry_id": journal_id
}