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): """ 仕訳をソフト削除し、is_latest フラグを自動更新 ポイント: - original_entry_id を持つ版本チェーン全体は影響を受けない - 削除されたのが最新版の場合、前の版本を新しい is_latest にする - 軟削除(is_deleted=true)なので完全な履歴は保持される """ with get_connection() as conn: with conn.cursor() as cur: # ① 削除対象の仕訳情報を取得 cur.execute(""" SELECT journal_entry_id, original_entry_id, is_latest, revision_count FROM journal_entries WHERE journal_entry_id = %s AND is_deleted = false """, (journal_id,)) row = cur.fetchone() if not row: raise HTTPException( status_code=404, detail="仕訳が存在しないか、既に削除されています" ) original_entry_id = row["original_entry_id"] is_latest = row["is_latest"] # ② ソフト削除を実行 cur.execute(""" UPDATE journal_entries SET is_deleted = true, updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Tokyo' WHERE journal_entry_id = %s """, (journal_id,)) # ③ 削除されたのが最新版の場合のみ、新しい最新版を設定 if is_latest: # 同じ original_entry_id を持つ未削除版の中で、最新の revision_count を探す cur.execute(""" SELECT journal_entry_id FROM journal_entries WHERE (original_entry_id = %s OR journal_entry_id = %s) AND is_deleted = false AND journal_entry_id != %s ORDER BY revision_count DESC LIMIT 1 """, (original_entry_id or journal_id, original_entry_id or journal_id, journal_id)) new_latest = cur.fetchone() if new_latest: cur.execute(""" UPDATE journal_entries SET is_latest = true WHERE journal_entry_id = %s """, (new_latest["journal_entry_id"],)) conn.commit() return { "status": "deleted", "journal_entry_id": journal_id, "message": "仕訳を削除しました(完全な履歴は保持されます)" }