修正
This commit is contained in:
88
backend/DATABASE_MIGRATION_GUIDE.md
Normal file
88
backend/DATABASE_MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# 数据库迁移指南
|
||||
|
||||
## 问题描述
|
||||
|
||||
前端代码已更新以支持新的版本追踪系统(使用 `revision_count`, `is_latest`, `original_entry_id` 字段)。但是,数据库中还没有这些字段。
|
||||
|
||||
### 错误信息
|
||||
|
||||
```
|
||||
psycopg.errors.UndefinedColumn: column je.revision_count does not exist
|
||||
```
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 步骤 1:以数据库管理员身份连接
|
||||
|
||||
使用 `postgres` 超级用户或具有 DDL 权限的用户连接到数据库。
|
||||
|
||||
**在 NAS/Docker 中执行:**
|
||||
|
||||
```bash
|
||||
docker exec <postgresql_container_name> psql -U postgres -d njts_acct
|
||||
```
|
||||
|
||||
**或使用 pgAdmin:**
|
||||
|
||||
- 连接到 PostgreSQL 服务器(默认地址:`192.168.0.61:55432`)
|
||||
- 使用 postgres 用户和密码登录
|
||||
- 选择 `njts_acct` 数据库
|
||||
|
||||
### 步骤 2:执行迁移 SQL
|
||||
|
||||
复制并执行 [admin_migration_add_fields.sql](./admin_migration_add_fields.sql) 中的 SQL 代码。
|
||||
|
||||
**在 psql 中:**
|
||||
|
||||
```sql
|
||||
-- 复制 admin_migration_add_fields.sql 的所有内容并在 psql 中粘贴执行
|
||||
```
|
||||
|
||||
**或使用文件:**
|
||||
|
||||
```bash
|
||||
docker exec <postgresql_container_id> psql -U postgres -d njts_acct -f /path/to/admin_migration_add_fields.sql
|
||||
```
|
||||
|
||||
### 步骤 3:验证迁移
|
||||
|
||||
执行以下查询查看新字段:
|
||||
|
||||
```sql
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries'
|
||||
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
|
||||
ORDER BY ordinal_position;
|
||||
```
|
||||
|
||||
应该看到 3 个新字段:
|
||||
|
||||
- `is_latest` (BOOLEAN) - 标记是否为最新版本
|
||||
- `revision_count` (INTEGER) - 修正版次数(1=原始,2+=修正版本)
|
||||
- `original_entry_id` (INTEGER) - 指向原始交易
|
||||
|
||||
## 临时解决方案(迁移前)
|
||||
|
||||
后端代码已经过修改,可以在字段不存在时继续工作:
|
||||
|
||||
- 所有仕訳将显示为最新版本(`is_latest = true`)
|
||||
- 所有仕訳的 `revision_count` 将显示为 1
|
||||
- 修改履历功能暂时不可用
|
||||
|
||||
一旦迁移完成,系统将自动启用完整的版本追踪功能。
|
||||
|
||||
## 数据库连接信息
|
||||
|
||||
```
|
||||
Host: 192.168.0.61
|
||||
Port: 55432
|
||||
Database: njts_acct
|
||||
User (普通): njts_app
|
||||
User (管理): postgres
|
||||
```
|
||||
|
||||
## 相关文件
|
||||
|
||||
- [admin_migration_add_fields.sql](./admin_migration_add_fields.sql) - 管理员执行的 SQL 迁移脚本
|
||||
- [journal_entries.py](../app/routers/journal_entries.py) - 已修改为支持字段缺失时的降级处理
|
||||
143
backend/analyze_sales_revenue.py
Normal file
143
backend/analyze_sales_revenue.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Analyze the composition of 2025年6月 売上高 debit 3,310,000
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.core.database import get_connection
|
||||
from datetime import date
|
||||
|
||||
def analyze():
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
print("=" * 80)
|
||||
print("【2025年6月 売上高借方 3,310,000 の分析】")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Get account_id for 701
|
||||
cur.execute("SELECT account_id FROM accounts WHERE account_code = '701'")
|
||||
acc_result = cur.fetchone()
|
||||
if not acc_result:
|
||||
print("701科目が見つかりません")
|
||||
return
|
||||
|
||||
account_id = acc_result['account_id']
|
||||
|
||||
print("【ステップ1: 2025年6月の売上高借方明細(最新版のみ)】")
|
||||
print()
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
j.journal_entry_id,
|
||||
j.description,
|
||||
j.entry_date,
|
||||
j.is_latest,
|
||||
j.created_at,
|
||||
l.debit,
|
||||
l.credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_entry_id = l.journal_entry_id
|
||||
WHERE l.account_id = %s
|
||||
AND EXTRACT(YEAR FROM j.entry_date) = 2025
|
||||
AND EXTRACT(MONTH FROM j.entry_date) = 6
|
||||
AND j.is_deleted = false
|
||||
AND j.is_latest = true
|
||||
ORDER BY j.entry_date, j.journal_entry_id
|
||||
""", (account_id,))
|
||||
|
||||
entries = cur.fetchall()
|
||||
total_debit = 0
|
||||
total_credit = 0
|
||||
|
||||
for i, e in enumerate(entries, 1):
|
||||
print(f"{i}. ID={e['journal_entry_id']:4d}: {e['description']:<30s} ({e['entry_date']})")
|
||||
print(f" 借={e['debit']:>12.0f} 貸={e['credit']:>12.0f} is_latest={e['is_latest']}")
|
||||
total_debit += e['debit']
|
||||
total_credit += e['credit']
|
||||
|
||||
print()
|
||||
print(f"【合計】借方: {total_debit:>12,.0f} 貸方: {total_credit:>12,.0f}")
|
||||
print()
|
||||
|
||||
# Double check: also include old versions to see the difference
|
||||
print("【ステップ2: 参考 - すべてのバージョン(is_latest無関係)】")
|
||||
print()
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
j.journal_entry_id,
|
||||
j.description,
|
||||
j.entry_date,
|
||||
j.is_latest,
|
||||
j.is_deleted,
|
||||
l.debit,
|
||||
l.credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_entry_id = l.journal_entry_id
|
||||
WHERE l.account_id = %s
|
||||
AND EXTRACT(YEAR FROM j.entry_date) = 2025
|
||||
AND EXTRACT(MONTH FROM j.entry_date) = 6
|
||||
AND j.is_deleted = false
|
||||
ORDER BY j.entry_date, j.journal_entry_id
|
||||
""", (account_id,))
|
||||
|
||||
all_entries = cur.fetchall()
|
||||
all_debit = 0
|
||||
all_credit = 0
|
||||
|
||||
for e in all_entries:
|
||||
latest_mark = "✓最新" if e['is_latest'] else "✗旧版"
|
||||
print(f"ID={e['journal_entry_id']:4d}: {e['description']:<30s} ({e['entry_date']}) {latest_mark}")
|
||||
print(f" 借={e['debit']:>12,.0f} 貸={e['credit']:>12,.0f}")
|
||||
all_debit += e['debit']
|
||||
all_credit += e['credit']
|
||||
|
||||
print()
|
||||
if all_debit != total_debit:
|
||||
print(f"【注意】すべてのバージョン: 借方={all_debit:>12,.0f} 貸方={all_credit:>12,.0f}")
|
||||
print(f" 最新版のみ: 借方={total_debit:>12,.0f} 貸方={total_credit:>12,.0f}")
|
||||
print(f" 差分: 借方={all_debit - total_debit:>12,.0f}(古い版の分)")
|
||||
else:
|
||||
print(f"✓ 最新版のみのデータ = すべてのバージョン(修正なし)")
|
||||
|
||||
print()
|
||||
print("【ステップ3: 修正チェーンの確認】")
|
||||
|
||||
# Check if any of these entries are part of a correction chain
|
||||
cur.execute("""
|
||||
SELECT DISTINCT
|
||||
COALESCE(j.original_entry_id, j.journal_entry_id) as chain_id,
|
||||
COUNT(*) as count_in_chain
|
||||
FROM journal_entries j
|
||||
WHERE j.journal_entry_id IN (
|
||||
SELECT DISTINCT l.journal_entry_id
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_entry_id = l.journal_entry_id
|
||||
WHERE l.account_id = %s
|
||||
AND EXTRACT(YEAR FROM j.entry_date) = 2025
|
||||
AND EXTRACT(MONTH FROM j.entry_date) = 6
|
||||
AND j.is_deleted = false
|
||||
)
|
||||
GROUP BY COALESCE(j.original_entry_id, j.journal_entry_id)
|
||||
HAVING COUNT(*) > 1
|
||||
""", (account_id,))
|
||||
|
||||
chains = cur.fetchall()
|
||||
if chains:
|
||||
print("修正チェーンが見つかりました:")
|
||||
for chain in chains:
|
||||
print(f" Chain {chain['chain_id']}: {chain['count_in_chain']} バージョン")
|
||||
else:
|
||||
print("修正チェーンなし(すべて単独のバージョン)")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
analyze()
|
||||
except Exception as e:
|
||||
print(f"エラー: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
174
backend/app/core/revision_manager.py
Normal file
174
backend/app/core/revision_manager.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
修正版本追踪管理器
|
||||
記錄日記條目的修正歷史,追踪原始交易和修正版本
|
||||
"""
|
||||
|
||||
from decimal import Decimal
|
||||
from datetime import date
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
def mark_old_version_as_outdated(conn, original_entry_id: int) -> None:
|
||||
"""
|
||||
將舊版本標記為非最新版本 (is_latest = false)
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
UPDATE journal_entries
|
||||
SET is_latest = false
|
||||
WHERE original_entry_id = %s OR journal_entry_id = %s
|
||||
ORDER BY created_at DESC
|
||||
OFFSET 1
|
||||
""", (original_entry_id, original_entry_id))
|
||||
|
||||
|
||||
def create_new_revision(
|
||||
conn,
|
||||
entry_date: date,
|
||||
description: str,
|
||||
fiscal_year: int,
|
||||
lines: list,
|
||||
created_by: str = "system",
|
||||
original_entry_id: Optional[int] = None
|
||||
) -> int:
|
||||
"""
|
||||
創建新的修正版本
|
||||
|
||||
返回: 新創建的 journal_entry_id
|
||||
"""
|
||||
|
||||
with conn.cursor() as cur:
|
||||
# ① 如果是修正交易,獲取最新版本的 revision_count
|
||||
revision_count = 1
|
||||
if original_entry_id:
|
||||
# 获取原始交易 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:
|
||||
# 如果找不到最新版本,說明 original_entry_id 是第一個版本
|
||||
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))
|
||||
|
||||
# ③ 創建新版本
|
||||
normalized_original_id = original_entry_id if original_entry_id else None
|
||||
|
||||
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
|
||||
""", (
|
||||
entry_date,
|
||||
description,
|
||||
fiscal_year,
|
||||
revision_count,
|
||||
normalized_original_id,
|
||||
created_by
|
||||
))
|
||||
|
||||
entry_id = cur.fetchone()["journal_entry_id"]
|
||||
|
||||
# ④ 插入交易明細
|
||||
for line in lines:
|
||||
# 轉換 tax_direction
|
||||
tax_dir_db = None
|
||||
if line.get("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.get("account_id"),
|
||||
line.get("debit", Decimal("0")),
|
||||
line.get("credit", Decimal("0")),
|
||||
line.get("tax_rate"),
|
||||
tax_dir_db
|
||||
))
|
||||
|
||||
return entry_id
|
||||
|
||||
|
||||
def get_current_version(conn, journal_entry_id: int) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
取得指定交易的最新版本資訊
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
journal_entry_id,
|
||||
entry_date,
|
||||
description,
|
||||
revision_count,
|
||||
original_entry_id,
|
||||
is_latest,
|
||||
created_at,
|
||||
created_by
|
||||
FROM journal_entries
|
||||
WHERE journal_entry_id = %s
|
||||
""", (journal_entry_id,))
|
||||
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return dict(row)
|
||||
return None
|
||||
|
||||
|
||||
def get_revision_history(conn, original_entry_id: int) -> list:
|
||||
"""
|
||||
取得某個交易的所有修正版本歷史
|
||||
"""
|
||||
with conn.cursor() as cur:
|
||||
cur.execute("""
|
||||
SELECT
|
||||
journal_entry_id,
|
||||
entry_date,
|
||||
description,
|
||||
revision_count,
|
||||
is_latest,
|
||||
created_at,
|
||||
created_by
|
||||
FROM journal_entries
|
||||
WHERE original_entry_id = %s OR journal_entry_id = %s
|
||||
ORDER BY created_at ASC
|
||||
""", (original_entry_id, original_entry_id))
|
||||
|
||||
rows = cur.fetchall()
|
||||
return [dict(row) for row in rows]
|
||||
157
backend/app/db_auto_migration.py
Normal file
157
backend/app/db_auto_migration.py
Normal file
@@ -0,0 +1,157 @@
|
||||
"""
|
||||
自動數據庫遷移 - 添加修正追踪欄位
|
||||
在應用啟動時執行
|
||||
"""
|
||||
|
||||
import os
|
||||
from psycopg import connect
|
||||
|
||||
def run_auto_migration():
|
||||
"""自動遷移:添加修正追踪欄位"""
|
||||
|
||||
try:
|
||||
conn = connect(
|
||||
host=os.getenv("DB_HOST"),
|
||||
port=int(os.getenv("DB_PORT")),
|
||||
dbname=os.getenv("DB_NAME"),
|
||||
user=os.getenv("DB_USER"),
|
||||
password=os.getenv("DB_PASSWORD"),
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
print("\n" + "=" * 80)
|
||||
print("【自動數據庫遷移】")
|
||||
print("=" * 80)
|
||||
|
||||
# 1. 添加 is_latest 欄位
|
||||
print("\n【步驟 1】檢查 is_latest 欄位...")
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'is_latest'
|
||||
""")
|
||||
|
||||
if cur.fetchone()[0] == 0:
|
||||
# 欄位不存在,添加
|
||||
try:
|
||||
cur.execute("""
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN is_latest BOOLEAN DEFAULT true
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ 欄位 is_latest 已添加")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"⚠️ 無法添加 is_latest(可能由於權限): {e}")
|
||||
else:
|
||||
print("✓ 欄位 is_latest 已存在")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 檢查 is_latest 失敗: {e}")
|
||||
|
||||
# 2. 添加 revision_count 欄位
|
||||
print("\n【步驟 2】檢查 revision_count 欄位...")
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'revision_count'
|
||||
""")
|
||||
|
||||
if cur.fetchone()[0] == 0:
|
||||
try:
|
||||
cur.execute("""
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN revision_count INTEGER DEFAULT 1
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ 欄位 revision_count 已添加")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"⚠️ 無法添加 revision_count(可能由於權限): {e}")
|
||||
else:
|
||||
print("✓ 欄位 revision_count 已存在")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 檢查 revision_count 失敗: {e}")
|
||||
|
||||
# 3. 添加 original_entry_id 欄位
|
||||
print("\n【步驟 3】檢查 original_entry_id 欄位...")
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT COUNT(*)
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'original_entry_id'
|
||||
""")
|
||||
|
||||
if cur.fetchone()[0] == 0:
|
||||
try:
|
||||
cur.execute("""
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN original_entry_id INTEGER
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ 欄位 original_entry_id 已添加")
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
print(f"⚠️ 無法添加 original_entry_id(可能由於權限): {e}")
|
||||
else:
|
||||
print("✓ 欄位 original_entry_id 已存在")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 檢查 original_entry_id 失敗: {e}")
|
||||
|
||||
# 4. 創建索引
|
||||
print("\n【步驟 4】創建索引...")
|
||||
try:
|
||||
cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest
|
||||
ON journal_entries(is_latest, entry_date)
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ 索引 idx_journal_entries_is_latest 已創建")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 無法創建索引: {e}")
|
||||
|
||||
# 5. 驗證
|
||||
print("\n【步驟 5】驗證新欄位...")
|
||||
try:
|
||||
cur.execute("""
|
||||
SELECT column_name, data_type
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries'
|
||||
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
|
||||
ORDER BY ordinal_position
|
||||
""")
|
||||
|
||||
rows = cur.fetchall()
|
||||
if rows:
|
||||
for row in rows:
|
||||
col_name = row[0]
|
||||
data_type = row[1]
|
||||
print(f" ✓ {col_name}: {data_type}")
|
||||
else:
|
||||
print(" ℹ️ 新欄位尚未存在(需要管理員執行遷移 SQL)")
|
||||
except Exception as e:
|
||||
print(f"⚠️ 驗證失敗: {e}")
|
||||
|
||||
print("\n" + "=" * 80)
|
||||
print("✓ 自動遷移檢查完成!")
|
||||
print("=" * 80 + "\n")
|
||||
|
||||
conn.close()
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 無法連接到數據庫或執行遷移: {e}")
|
||||
print("\n提示:")
|
||||
print("1. 確認數據庫連接信息正確(DB_HOST, DB_PORT, DB_NAME, DB_USER, DB_PASSWORD)")
|
||||
print("2. 如果字段仍不存在,請使用管理員帳戶執行以下 SQL 命令:")
|
||||
print("""
|
||||
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS is_latest BOOLEAN DEFAULT true;
|
||||
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS revision_count INTEGER DEFAULT 1;
|
||||
ALTER TABLE journal_entries ADD COLUMN IF NOT EXISTS original_entry_id INTEGER;
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest ON journal_entries(is_latest, entry_date);
|
||||
""")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_auto_migration()
|
||||
|
||||
@@ -21,6 +21,18 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# ─────────────────────────
|
||||
# 自動データベース遷移
|
||||
# ─────────────────────────
|
||||
@app.on_event("startup")
|
||||
async def run_startup_migration():
|
||||
"""アプリケーション起動時にデータベス遷移を実行"""
|
||||
try:
|
||||
from app.db_auto_migration import run_auto_migration
|
||||
run_auto_migration()
|
||||
except Exception as e:
|
||||
print(f"⚠️ 遷移エラー(無視): {e}")
|
||||
|
||||
@app.exception_handler(ValidationError)
|
||||
async def validation_exception_handler(request: Request, exc: ValidationError):
|
||||
# ここでは最初のエラーだけ簡潔に返す(必要に応じて整形)
|
||||
|
||||
@@ -61,19 +61,16 @@ def fetch_trial_balance(date_from: str, date_to: str):
|
||||
opening_balance_from_table = D(ob_row["ob"]) if ob_row else D(0)
|
||||
|
||||
# date_from より前の仕訳による増減
|
||||
# 修正された仕訳(親となった仕訳)は除外
|
||||
# 修正された仕訳(is_latest = false)は除外
|
||||
cur.execute("""
|
||||
SELECT COALESCE(SUM(l.debit - l.credit), 0) AS opening
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j
|
||||
ON j.journal_entry_id = l.journal_entry_id
|
||||
LEFT JOIN journal_entries child
|
||||
ON child.parent_entry_id = j.journal_entry_id
|
||||
WHERE l.account_id = %s
|
||||
AND j.entry_date < %s
|
||||
AND j.is_deleted = false
|
||||
AND j.description NOT LIKE '[逆仕訳]%%'
|
||||
AND child.journal_entry_id IS NULL
|
||||
AND j.is_latest = true
|
||||
""", (aid, date_from))
|
||||
opening_from_journals = D(cur.fetchone()["opening"])
|
||||
|
||||
@@ -81,7 +78,7 @@ def fetch_trial_balance(date_from: str, date_to: str):
|
||||
opening = opening_balance_from_table + opening_from_journals
|
||||
|
||||
# 当期増減(date_from ~ date_to)
|
||||
# 修正された仕訳(親となった仕訳)は除外
|
||||
# 修正された仕訳(is_latest = false)は除外
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(SUM(l.debit), 0) AS debit,
|
||||
@@ -89,13 +86,10 @@ def fetch_trial_balance(date_from: str, date_to: str):
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j
|
||||
ON j.journal_entry_id = l.journal_entry_id
|
||||
LEFT JOIN journal_entries child
|
||||
ON child.parent_entry_id = j.journal_entry_id
|
||||
WHERE l.account_id = %s
|
||||
AND j.entry_date BETWEEN %s AND %s
|
||||
AND j.is_deleted = false
|
||||
AND j.description NOT LIKE '[逆仕訳]%%'
|
||||
AND child.journal_entry_id IS NULL
|
||||
AND j.is_latest = true
|
||||
""", (aid, date_from, date_to))
|
||||
row = cur.fetchone()
|
||||
debit = D(row["debit"])
|
||||
|
||||
@@ -23,7 +23,8 @@ class JournalEntryRequest(BaseModel):
|
||||
tax_paid_account_id: Optional[int] = None # 仮払消費税等 勘定
|
||||
tax_received_account_id: Optional[int] = None # 仮受消費税等 勘定
|
||||
rounding_mode: str = "round" # "round", "floor", or "ceil"
|
||||
parent_entry_id: Optional[int] = None # 修正元の仕訳ID
|
||||
original_entry_id: Optional[int] = None # 修正元の仕訳ID(新版本追踪システム用)
|
||||
parent_entry_id: Optional[int] = None # 互換性のため残す(非推奨)
|
||||
|
||||
class JournalEntryUpdateRequest(BaseModel):
|
||||
description: str
|
||||
@@ -41,23 +42,75 @@ def get_journal_entries(
|
||||
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:
|
||||
# クエリ構築
|
||||
if include_history:
|
||||
# 修正履歴を含むすべてのバージョンを表示
|
||||
# 首先检查字段是否存在
|
||||
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,
|
||||
je.version,
|
||||
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
|
||||
@@ -66,29 +119,6 @@ def get_journal_entries(
|
||||
WHERE je.is_deleted = false
|
||||
"""
|
||||
params = []
|
||||
else:
|
||||
# 最新バージョンのみ表示:parent_entry_idを持つ古い仕訳と逆仕訳を除外
|
||||
sql = """
|
||||
SELECT
|
||||
je.journal_entry_id,
|
||||
je.entry_date,
|
||||
je.description,
|
||||
je.fiscal_year,
|
||||
je.version,
|
||||
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.description NOT LIKE %s
|
||||
AND je.journal_entry_id NOT IN (
|
||||
SELECT parent_entry_id
|
||||
FROM journal_entries
|
||||
WHERE parent_entry_id IS NOT NULL
|
||||
)
|
||||
"""
|
||||
params = ['%[逆仕訳]%']
|
||||
|
||||
if from_date:
|
||||
sql += " AND je.entry_date >= %s"
|
||||
@@ -102,7 +132,24 @@ def get_journal_entries(
|
||||
sql += " AND je.description LIKE %s"
|
||||
params.append(f"%{keyword}%")
|
||||
|
||||
if account_id:
|
||||
# 科目と方向で絞込(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
|
||||
@@ -111,8 +158,18 @@ def get_journal_entries(
|
||||
params.append(account_id)
|
||||
|
||||
sql += """
|
||||
GROUP BY je.journal_entry_id, je.entry_date, je.description, je.fiscal_year, je.version
|
||||
ORDER BY je.entry_date DESC, je.journal_entry_id DESC, je.version DESC
|
||||
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)
|
||||
@@ -135,7 +192,9 @@ def get_journal_entry(journal_id: int):
|
||||
entry_date,
|
||||
description,
|
||||
fiscal_year,
|
||||
version,
|
||||
revision_count,
|
||||
is_latest,
|
||||
original_entry_id,
|
||||
is_deleted
|
||||
FROM journal_entries
|
||||
WHERE journal_entry_id = %s
|
||||
@@ -170,6 +229,54 @@ def get_journal_entry(journal_id: int):
|
||||
}
|
||||
|
||||
|
||||
@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):
|
||||
"""
|
||||
@@ -260,6 +367,9 @@ def create_journal_entry(req: JournalEntryRequest):
|
||||
|
||||
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 = []
|
||||
@@ -318,41 +428,79 @@ def create_journal_entry(req: JournalEntryRequest):
|
||||
fiscal_year = req.entry_date.year
|
||||
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
# 仕訳ヘッダ
|
||||
# 检查版本追踪字段是否存在
|
||||
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,
|
||||
version,
|
||||
is_latest,
|
||||
revision_count,
|
||||
original_entry_id,
|
||||
is_deleted,
|
||||
created_by,
|
||||
parent_entry_id
|
||||
created_by
|
||||
)
|
||||
VALUES (%s, %s, %s, 1, false, %s, %s)
|
||||
VALUES (%s, %s, %s, true, %s, %s, false, %s)
|
||||
RETURNING journal_entry_id
|
||||
""", (
|
||||
req.entry_date,
|
||||
req.description,
|
||||
fiscal_year,
|
||||
"system",
|
||||
req.parent_entry_id
|
||||
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の値を変換: paid->INPUT, received->OUTPUT, none->NONE
|
||||
# 轉換 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'
|
||||
elif line.tax_direction == 'none':
|
||||
tax_dir_db = 'NONE'
|
||||
else:
|
||||
tax_dir_db = line.tax_direction.upper()
|
||||
|
||||
@@ -374,13 +522,60 @@ def create_journal_entry(req: JournalEntryRequest):
|
||||
line.tax_rate,
|
||||
tax_dir_db
|
||||
))
|
||||
|
||||
conn.commit()
|
||||
|
||||
return {
|
||||
"status": "ok",
|
||||
"journal_entry_id": entry_id
|
||||
}
|
||||
|
||||
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}")
|
||||
|
||||
@@ -14,19 +14,53 @@ def get_trial_balance(
|
||||
):
|
||||
print("### trial-balance OPTIONAL PARAMS ACTIVE ###")
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
a.account_id,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(l.debit), 0) AS debit,
|
||||
COALESCE(SUM(l.credit), 0) AS credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries e ON e.journal_entry_id = l.journal_entry_id
|
||||
JOIN accounts a ON a.account_id = l.account_id
|
||||
WHERE 1 = 1
|
||||
"""
|
||||
with get_connection() as conn:
|
||||
# 检查 is_latest 字段是否存在(新的版本追踪系统)
|
||||
with conn.cursor() as check_cur:
|
||||
check_cur.execute("""
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'is_latest'
|
||||
""")
|
||||
has_is_latest = check_cur.fetchone()[0] > 0
|
||||
|
||||
# 构建查询语句
|
||||
if has_is_latest:
|
||||
# 新系统:只取最新版本(is_latest = true)
|
||||
sql = """
|
||||
SELECT
|
||||
a.account_id,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(l.debit), 0) AS debit,
|
||||
COALESCE(SUM(l.credit), 0) AS credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries e ON e.journal_entry_id = l.journal_entry_id
|
||||
JOIN accounts a ON a.account_id = l.account_id
|
||||
WHERE e.is_deleted = false
|
||||
AND e.is_latest = true
|
||||
"""
|
||||
else:
|
||||
# 旧系统:排除已修正的记录(使用 parent_entry_id)
|
||||
sql = """
|
||||
SELECT
|
||||
a.account_id,
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
a.account_type,
|
||||
COALESCE(SUM(l.debit), 0) AS debit,
|
||||
COALESCE(SUM(l.credit), 0) AS credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries e ON e.journal_entry_id = l.journal_entry_id
|
||||
JOIN accounts a ON a.account_id = l.account_id
|
||||
WHERE e.is_deleted = false
|
||||
AND e.journal_entry_id NOT IN (
|
||||
SELECT parent_entry_id
|
||||
FROM journal_entries
|
||||
WHERE parent_entry_id IS NOT NULL
|
||||
)
|
||||
"""
|
||||
|
||||
params = {}
|
||||
|
||||
@@ -35,11 +69,11 @@ def get_trial_balance(
|
||||
params["fiscal_year"] = fiscal_year
|
||||
|
||||
if date_from is not None:
|
||||
sql += " AND e.journal_date >= %(date_from)s"
|
||||
sql += " AND e.entry_date >= %(date_from)s"
|
||||
params["date_from"] = date_from
|
||||
|
||||
if date_to is not None:
|
||||
sql += " AND e.journal_date <= %(date_to)s"
|
||||
sql += " AND e.entry_date <= %(date_to)s"
|
||||
params["date_to"] = date_to
|
||||
|
||||
sql += """
|
||||
|
||||
101
backend/cleanup_script.py
Normal file
101
backend/cleanup_script.py
Normal file
@@ -0,0 +1,101 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Cleanup script for running inside Docker container
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add current dir to path so we can import app
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.core.database import get_connection
|
||||
|
||||
def cleanup():
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
print("=" * 80)
|
||||
print("【データベースクリーンアップ】")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Step 1: Find orphan reverse entries
|
||||
print("【ステップ1: 削除対象となる逆仕訳を検索】")
|
||||
cur.execute("""
|
||||
SELECT journal_entry_id, description, entry_date, is_latest
|
||||
FROM journal_entries
|
||||
WHERE is_deleted = false AND is_latest = false
|
||||
AND parent_entry_id IS NULL AND original_entry_id IS NULL
|
||||
ORDER BY created_at DESC
|
||||
""")
|
||||
rows = cur.fetchall()
|
||||
print(f" 見つかったレコード: {len(rows)} 件")
|
||||
for i, r in enumerate(rows[:5]):
|
||||
print(f" {i+1}. ID={r['journal_entry_id']}: {r['description']} ({r['entry_date']})")
|
||||
if len(rows) > 5:
|
||||
print(f" ... 他 {len(rows)-5} 件")
|
||||
|
||||
if len(rows) > 0:
|
||||
print()
|
||||
print("【ステップ2: レコードを削除】")
|
||||
ids = [r['journal_entry_id'] for r in rows]
|
||||
|
||||
# Delete journal_lines first
|
||||
cur.execute("DELETE FROM journal_lines WHERE journal_entry_id = ANY(%s)", (ids,))
|
||||
lines_cnt = cur.rowcount
|
||||
|
||||
# Delete journal_entries
|
||||
cur.execute("DELETE FROM journal_entries WHERE journal_entry_id = ANY(%s)", (ids,))
|
||||
entries_cnt = cur.rowcount
|
||||
|
||||
conn.commit()
|
||||
print(f" 削除完了: {entries_cnt} 仕訳エントリ, {lines_cnt} 仕訳行")
|
||||
else:
|
||||
print(" 削除対象のレコードがありません")
|
||||
|
||||
print()
|
||||
print("【ステップ3: クリーンアップ後の統計】")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM journal_entries WHERE is_deleted = false")
|
||||
total = cur.fetchone()
|
||||
print(f" 総レコード数: {total['count']}")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM journal_entries WHERE is_deleted = false AND is_latest = true")
|
||||
latest = cur.fetchone()
|
||||
print(f" is_latest=true: {latest['count']}")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM journal_entries WHERE is_deleted = false AND is_latest = false")
|
||||
old = cur.fetchone()
|
||||
print(f" is_latest=false: {old['count']}")
|
||||
|
||||
# Check data integrity
|
||||
print()
|
||||
print("【データ整合性の確認】")
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
COALESCE(original_entry_id, journal_entry_id) as chain_id,
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN is_latest = true THEN 1 ELSE 0 END) as latest_cnt
|
||||
FROM journal_entries
|
||||
WHERE is_deleted = false
|
||||
GROUP BY COALESCE(original_entry_id, journal_entry_id)
|
||||
HAVING COUNT(*) > 1
|
||||
""")
|
||||
chains = cur.fetchall()
|
||||
print(f" 複数バージョンのチェーン: {len(chains)} 件")
|
||||
if chains:
|
||||
for chain in chains[:3]:
|
||||
print(f" Chain {chain['chain_id']}: 合計={chain['total']}, latest={chain['latest_cnt']}")
|
||||
|
||||
print()
|
||||
print("✓ クリーンアップ完了")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
cleanup()
|
||||
except Exception as e:
|
||||
print(f"エラー: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
140
backend/delete_reverse_entries.py
Normal file
140
backend/delete_reverse_entries.py
Normal file
@@ -0,0 +1,140 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Delete all journal entries with "逆仕訳" in description
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.core.database import get_connection
|
||||
|
||||
def delete_reverse_entries():
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
print("=" * 80)
|
||||
print("【「逆仕訳」データの削除(外键制約を考慮)】")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
# Step 1: Find and list all entries with "逆仕訳"
|
||||
print("【ステップ1: 削除対象データを検索】")
|
||||
cur.execute("""
|
||||
SELECT
|
||||
journal_entry_id,
|
||||
description,
|
||||
entry_date,
|
||||
is_latest
|
||||
FROM journal_entries
|
||||
WHERE description LIKE '%逆仕訳%'
|
||||
AND is_deleted = false
|
||||
ORDER BY journal_entry_id
|
||||
""")
|
||||
|
||||
reverse_entries = cur.fetchall()
|
||||
print(f"「逆仕訳」が含まれるレコード: {len(reverse_entries)} 件")
|
||||
|
||||
if len(reverse_entries) == 0:
|
||||
print("削除対象のレコードがありません")
|
||||
return
|
||||
|
||||
ids_to_delete = [e['journal_entry_id'] for e in reverse_entries]
|
||||
|
||||
# Step 2: Check for references
|
||||
print()
|
||||
print("【ステップ2: 外键参照をチェック】")
|
||||
|
||||
cur.execute("""
|
||||
SELECT DISTINCT parent_entry_id, COUNT(*) as count
|
||||
FROM journal_entries
|
||||
WHERE parent_entry_id = ANY(%s)
|
||||
AND is_deleted = false
|
||||
GROUP BY parent_entry_id
|
||||
""", (ids_to_delete,))
|
||||
|
||||
refs = cur.fetchall()
|
||||
if refs:
|
||||
print(f"参照されているレコード:")
|
||||
for ref in refs:
|
||||
print(f" ID={ref['parent_entry_id']}: {ref['count']} 個の子レコード")
|
||||
|
||||
# Step 3: Handle references by clearing parent_entry_id
|
||||
print()
|
||||
print("【ステップ3: 参照をクリア】")
|
||||
|
||||
cur.execute("""
|
||||
UPDATE journal_entries
|
||||
SET parent_entry_id = NULL
|
||||
WHERE parent_entry_id = ANY(%s)
|
||||
""", (ids_to_delete,))
|
||||
|
||||
cleared = cur.rowcount
|
||||
print(f"クリアされたレコード: {cleared} 件")
|
||||
|
||||
# Step 4: Delete journal_lines
|
||||
print()
|
||||
print("【ステップ4: ジャーナル行を削除】")
|
||||
|
||||
cur.execute("DELETE FROM journal_lines WHERE journal_entry_id = ANY(%s)", (ids_to_delete,))
|
||||
lines_deleted = cur.rowcount
|
||||
print(f"削除されたジャーナル行: {lines_deleted} 行")
|
||||
|
||||
# Step 5: Delete journal_entries
|
||||
print()
|
||||
print("【ステップ5: ジャーナルエントリを削除】")
|
||||
|
||||
cur.execute("DELETE FROM journal_entries WHERE journal_entry_id = ANY(%s)", (ids_to_delete,))
|
||||
entries_deleted = cur.rowcount
|
||||
print(f"削除されたジャーナルエントリ: {entries_deleted} 件")
|
||||
|
||||
conn.commit()
|
||||
|
||||
# Step 6: Statistics
|
||||
print()
|
||||
print("【ステップ6: 削除後の統計】")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM journal_entries WHERE is_deleted = false")
|
||||
total = cur.fetchone()['count']
|
||||
print(f"総レコード数: {total}")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM journal_entries WHERE is_deleted = false AND is_latest = true")
|
||||
latest = cur.fetchone()['count']
|
||||
print(f" is_latest=true: {latest}")
|
||||
|
||||
cur.execute("SELECT COUNT(*) FROM journal_entries WHERE is_deleted = false AND is_latest = false")
|
||||
old = cur.fetchone()['count']
|
||||
print(f" is_latest=false: {old}")
|
||||
|
||||
# Step 7: Verify 2025年6月 sales revenue
|
||||
print()
|
||||
print("【ステップ7: 2025年6月の売上高を再確認】")
|
||||
|
||||
cur.execute("""
|
||||
SELECT
|
||||
SUM(l.debit) as total_debit,
|
||||
SUM(l.credit) as total_credit
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_entry_id = l.journal_entry_id
|
||||
JOIN accounts a ON a.account_id = l.account_id
|
||||
WHERE a.account_code = '701'
|
||||
AND EXTRACT(YEAR FROM j.entry_date) = 2025
|
||||
AND EXTRACT(MONTH FROM j.entry_date) = 6
|
||||
AND j.is_deleted = false
|
||||
AND j.is_latest = true
|
||||
""")
|
||||
|
||||
result = cur.fetchone()
|
||||
print(f"2025年6月 売上高(最新版のみ):")
|
||||
print(f" 借方: {result['total_debit']:>12,.0f}")
|
||||
print(f" 貸方: {result['total_credit']:>12,.0f}")
|
||||
|
||||
print()
|
||||
print("✓ 削除完了")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
delete_reverse_entries()
|
||||
except Exception as e:
|
||||
print(f"エラー: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
146
backend/run_db_migration.py
Normal file
146
backend/run_db_migration.py
Normal file
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
数据库迁移脚本:添加 revision_count, is_latest, original_entry_id 字段
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 加载 .env 文件
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
with open(env_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
key, value = line.split("=", 1)
|
||||
os.environ[key.strip()] = value.strip()
|
||||
|
||||
from psycopg import connect
|
||||
|
||||
def run_migration():
|
||||
try:
|
||||
# 从环境变量获取数据库连接信息
|
||||
db_password = os.getenv("DB_PASSWORD")
|
||||
print(f"📛 连接数据库: {os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}")
|
||||
print(f"👤 用户: {os.getenv('DB_USER')}")
|
||||
|
||||
conn = connect(
|
||||
host=os.getenv("DB_HOST"),
|
||||
port=int(os.getenv("DB_PORT")),
|
||||
dbname=os.getenv("DB_NAME"),
|
||||
user=os.getenv("DB_USER"),
|
||||
password=db_password if db_password else None,
|
||||
)
|
||||
|
||||
with conn.cursor() as cur:
|
||||
# 首先检查字段是否存在
|
||||
print("🔄 检查字段是否存在...")
|
||||
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_columns = {row[0] for row in cur.fetchall()}
|
||||
print(f"✓ 已存在的字段: {existing_columns if existing_columns else '无'}")
|
||||
|
||||
# 添加 is_latest 字段
|
||||
if 'is_latest' not in existing_columns:
|
||||
print("🔄 添加 is_latest 字段...")
|
||||
try:
|
||||
cur.execute("""
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN is_latest BOOLEAN DEFAULT true;
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ is_latest 字段已添加")
|
||||
except Exception as e:
|
||||
print(f"✗ 添加失败: {e}")
|
||||
conn.rollback()
|
||||
else:
|
||||
print("✓ is_latest 字段已存在")
|
||||
|
||||
# 添加 revision_count 字段
|
||||
if 'revision_count' not in existing_columns:
|
||||
print("🔄 添加 revision_count 字段...")
|
||||
try:
|
||||
cur.execute("""
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN revision_count INTEGER DEFAULT 1;
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ revision_count 字段已添加")
|
||||
except Exception as e:
|
||||
print(f"✗ 添加失败: {e}")
|
||||
conn.rollback()
|
||||
else:
|
||||
print("✓ revision_count 字段已存在")
|
||||
|
||||
# 添加 original_entry_id 字段
|
||||
if 'original_entry_id' not in existing_columns:
|
||||
print("🔄 添加 original_entry_id 字段...")
|
||||
try:
|
||||
cur.execute("""
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN original_entry_id INTEGER;
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ original_entry_id 字段已添加")
|
||||
except Exception as e:
|
||||
print(f"✗ 添加失败: {e}")
|
||||
conn.rollback()
|
||||
else:
|
||||
print("✓ original_entry_id 字段已存在")
|
||||
|
||||
# 创建索引
|
||||
print("🔄 创建索引...")
|
||||
try:
|
||||
cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest
|
||||
ON journal_entries(is_latest, entry_date);
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ idx_journal_entries_is_latest 索引已创建")
|
||||
except Exception as e:
|
||||
print(f"✗ 创建索引失败: {e}")
|
||||
conn.rollback()
|
||||
|
||||
try:
|
||||
cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_original_entry_id
|
||||
ON journal_entries(original_entry_id);
|
||||
""")
|
||||
conn.commit()
|
||||
print("✓ idx_journal_entries_original_entry_id 索引已创建")
|
||||
except Exception as e:
|
||||
print(f"✗ 创建索引失败: {e}")
|
||||
conn.rollback()
|
||||
|
||||
# 验证字段
|
||||
print("🔄 验证字段...")
|
||||
cur.execute("""
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries'
|
||||
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
|
||||
ORDER BY ordinal_position;
|
||||
""")
|
||||
|
||||
rows = cur.fetchall()
|
||||
print("\n📋 数据库字段信息:")
|
||||
for row in rows:
|
||||
print(f" - {row[0]}: {row[1]} (nullable: {row[2]})")
|
||||
|
||||
conn.close()
|
||||
|
||||
print("\n✅ 迁移完成!")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 迁移失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_migration())
|
||||
165
backend/run_migration.py
Normal file
165
backend/run_migration.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
数据库迁移脚本:添加 revision_count, is_latest, original_entry_id 字段
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 加载 .env 文件
|
||||
env_file = Path(__file__).parent.parent / ".env"
|
||||
if env_file.exists():
|
||||
with open(env_file) as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
key, value = line.split("=", 1)
|
||||
os.environ[key.strip()] = value.strip()
|
||||
|
||||
from psycopg import connect
|
||||
|
||||
def run_migration():
|
||||
try:
|
||||
# 从环境变量获取数据库连接信息
|
||||
db_password = os.getenv("DB_PASSWORD")
|
||||
print(f"📛 连接数据库: {os.getenv('DB_HOST')}:{os.getenv('DB_PORT')}/{os.getenv('DB_NAME')}")
|
||||
print(f"👤 用户: {os.getenv('DB_USER')}")
|
||||
|
||||
# 尝试用 njts_app 用户连接,不行的话尝试 postgres
|
||||
users_to_try = [
|
||||
{
|
||||
"user": os.getenv("DB_USER"),
|
||||
"password": db_password
|
||||
},
|
||||
{
|
||||
"user": "postgres",
|
||||
"password": "" # 空密码或尝试无密码
|
||||
}
|
||||
]
|
||||
|
||||
conn = None
|
||||
last_error = None
|
||||
|
||||
for cred in users_to_try:
|
||||
try:
|
||||
print(f"尝试连接用户: {cred['user']}")
|
||||
conn = connect(
|
||||
host=os.getenv("DB_HOST"),
|
||||
port=int(os.getenv("DB_PORT")),
|
||||
dbname=os.getenv("DB_NAME"),
|
||||
user=cred["user"],
|
||||
password=cred["password"] if cred["password"] else None,
|
||||
)
|
||||
print(f"✓ 成功连接为用户: {cred['user']}")
|
||||
break
|
||||
except Exception as e:
|
||||
last_error = e
|
||||
print(f"✗ 连接失败: {e}")
|
||||
continue
|
||||
|
||||
if not conn:
|
||||
raise Exception(f"无法连接到数据库。最后错误: {last_error}")
|
||||
|
||||
with conn.cursor() as cur:
|
||||
# 首先检查字段是否存在
|
||||
print("🔄 检查字段是否存在...")
|
||||
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_columns = {row[0] for row in cur.fetchall()}
|
||||
print(f"已存在的字段: {existing_columns}")
|
||||
|
||||
# 添加 is_latest 字段
|
||||
if 'is_latest' not in existing_columns:
|
||||
print("🔄 添加 is_latest 字段...")
|
||||
try:
|
||||
cur.execute("""
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN is_latest BOOLEAN DEFAULT true;
|
||||
""")
|
||||
print("✓ is_latest 字段已添加")
|
||||
except Exception as e:
|
||||
print(f"✗ 添加失败: {e}")
|
||||
else:
|
||||
print("✓ is_latest 字段已存在")
|
||||
print("✓ is_latest 字段处理完成")
|
||||
|
||||
# 添加 revision_count 字段
|
||||
print("🔄 检查 revision_count 字段...")
|
||||
cur.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'revision_count') THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN revision_count INTEGER DEFAULT 1;
|
||||
RAISE NOTICE '字段 revision_count 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '字段 revision_count 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
print("✓ revision_count 字段处理完成")
|
||||
|
||||
# 添加 original_entry_id 字段
|
||||
print("🔄 检查 original_entry_id 字段...")
|
||||
cur.execute("""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'original_entry_id') THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN original_entry_id INTEGER;
|
||||
ALTER TABLE journal_entries ADD CONSTRAINT fk_original_entry_id
|
||||
FOREIGN KEY (original_entry_id) REFERENCES journal_entries(journal_entry_id) ON DELETE CASCADE;
|
||||
RAISE NOTICE '字段 original_entry_id 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '字段 original_entry_id 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
""")
|
||||
print("✓ original_entry_id 字段处理完成")
|
||||
|
||||
# 创建索引
|
||||
print("🔄 创建索引...")
|
||||
cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest
|
||||
ON journal_entries(is_latest, entry_date);
|
||||
""")
|
||||
print("✓ idx_journal_entries_is_latest 索引已创建")
|
||||
|
||||
cur.execute("""
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_original_entry_id
|
||||
ON journal_entries(original_entry_id);
|
||||
""")
|
||||
print("✓ idx_journal_entries_original_entry_id 索引已创建")
|
||||
|
||||
# 验证字段
|
||||
print("🔄 验证字段...")
|
||||
cur.execute("""
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries'
|
||||
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
|
||||
ORDER BY ordinal_position;
|
||||
""")
|
||||
|
||||
rows = cur.fetchall()
|
||||
print("\n📋 数据库字段信息:")
|
||||
for row in rows:
|
||||
print(f" - {row[0]}: {row[1]} (nullable: {row[2]})")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
print("\n✅ 迁移完成!")
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n❌ 迁移失败: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(run_migration())
|
||||
100
backend/sql/admin_migration_add_fields.sql
Normal file
100
backend/sql/admin_migration_add_fields.sql
Normal file
@@ -0,0 +1,100 @@
|
||||
-- 【管理员执行】数据库迁移脚本
|
||||
-- 使用 postgres 或其他超级用户执行此脚本
|
||||
|
||||
-- ============================================================================
|
||||
-- 【步骤 1】添加缺失的字段到 journal_entries 表
|
||||
-- ============================================================================
|
||||
|
||||
-- 检查并添加 is_latest 字段
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'is_latest'
|
||||
) THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN is_latest BOOLEAN DEFAULT true;
|
||||
RAISE NOTICE '✓ 字段 is_latest 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '✓ 字段 is_latest 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 检查并添加 revision_count 字段
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'revision_count'
|
||||
) THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN revision_count INTEGER DEFAULT 1;
|
||||
RAISE NOTICE '✓ 字段 revision_count 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '✓ 字段 revision_count 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 检查并添加 original_entry_id 字段
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'original_entry_id'
|
||||
) THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN original_entry_id INTEGER;
|
||||
RAISE NOTICE '✓ 字段 original_entry_id 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '✓ 字段 original_entry_id 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 【步骤 2】添加外键约束(如果字段已添加)
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.table_constraints
|
||||
WHERE table_name = 'journal_entries' AND constraint_name = 'fk_original_entry_id'
|
||||
) THEN
|
||||
ALTER TABLE journal_entries
|
||||
ADD CONSTRAINT fk_original_entry_id
|
||||
FOREIGN KEY (original_entry_id)
|
||||
REFERENCES journal_entries(journal_entry_id)
|
||||
ON DELETE CASCADE;
|
||||
RAISE NOTICE '✓ 外键约束已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '✓ 外键约束已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 【步骤 3】创建索引以优化查询
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest
|
||||
ON journal_entries(is_latest, entry_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_original_entry_id
|
||||
ON journal_entries(original_entry_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- 【步骤 4】验证迁移结果
|
||||
-- ============================================================================
|
||||
|
||||
SELECT 'Migration Verification:' as status;
|
||||
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries'
|
||||
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
|
||||
ORDER BY ordinal_position;
|
||||
|
||||
SELECT 'Indexes:' as status;
|
||||
|
||||
SELECT indexname
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'journal_entries'
|
||||
AND (indexname LIKE 'idx_journal_entries_is_latest' OR indexname LIKE 'idx_journal_entries_original_entry_id');
|
||||
|
||||
SELECT '✓ 迁移完成!' as complete;
|
||||
30
backend/sql/migration_add_revision_tracking.sql
Normal file
30
backend/sql/migration_add_revision_tracking.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- Migration: Add is_latest and revision tracking columns
|
||||
|
||||
-- 1. 添加新欄位到 journal_entries
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN IF NOT EXISTS is_latest BOOLEAN DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS revision_count INTEGER DEFAULT 1,
|
||||
ADD COLUMN IF NOT EXISTS original_entry_id INTEGER REFERENCES journal_entries(journal_entry_id) ON DELETE CASCADE;
|
||||
|
||||
-- 2. 處理現有數據
|
||||
-- 將所有已刪除的交易標記為 is_latest = false
|
||||
UPDATE journal_entries
|
||||
SET is_latest = false
|
||||
WHERE is_deleted = true AND is_latest IS NOT NULL;
|
||||
|
||||
-- 將所有活躍交易標記為 is_latest = true
|
||||
UPDATE journal_entries
|
||||
SET is_latest = true
|
||||
WHERE is_deleted = false AND is_latest IS NULL;
|
||||
|
||||
-- 3. 創建索引以提高查詢性能
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest
|
||||
ON journal_entries(is_latest, entry_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_original_entry_id
|
||||
ON journal_entries(original_entry_id);
|
||||
|
||||
-- 4. 添加註釋說明
|
||||
COMMENT ON COLUMN journal_entries.is_latest IS '是否為最新版本(試算表使用此欄位過濾)';
|
||||
COMMENT ON COLUMN journal_entries.revision_count IS '修正版次(原始=1, 第1次修正=2, ...)';
|
||||
COMMENT ON COLUMN journal_entries.original_entry_id IS '指向原始交易(如果是修正版本)';
|
||||
70
backend/sql/migration_add_revision_tracking_safe.sql
Normal file
70
backend/sql/migration_add_revision_tracking_safe.sql
Normal file
@@ -0,0 +1,70 @@
|
||||
-- 【重要】此腳本需由數據庫擁有者或管理員在 PostgreSQL 中執行
|
||||
-- 在 PostgreSQL 客戶端運行 (如 pgAdmin 或 psql):
|
||||
-- psql -h 192.168.0.61 -p 55432 -U postgres -d njts_acct -f migration_add_revision_tracking.sql
|
||||
|
||||
-- 如果使用 Docker:
|
||||
-- docker exec <postgres_container> psql -U postgres -d njts_acct -c "copy below commands"
|
||||
|
||||
-- ============================================================================
|
||||
-- 【步驟 1】添加新欄位(如果不存在)
|
||||
-- ============================================================================
|
||||
|
||||
-- 檢查和添加 is_latest 欄位
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'is_latest') THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN is_latest BOOLEAN DEFAULT true;
|
||||
RAISE NOTICE '欄位 is_latest 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '欄位 is_latest 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 檢查和添加 revision_count 欄位
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'revision_count') THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN revision_count INTEGER DEFAULT 1;
|
||||
RAISE NOTICE '欄位 revision_count 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '欄位 revision_count 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- 檢查和添加 original_entry_id 欄位
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries' AND column_name = 'original_entry_id') THEN
|
||||
ALTER TABLE journal_entries ADD COLUMN original_entry_id INTEGER;
|
||||
ALTER TABLE journal_entries ADD CONSTRAINT fk_original_entry_id
|
||||
FOREIGN KEY (original_entry_id) REFERENCES journal_entries(journal_entry_id) ON DELETE CASCADE;
|
||||
RAISE NOTICE '欄位 original_entry_id 已添加';
|
||||
ELSE
|
||||
RAISE NOTICE '欄位 original_entry_id 已存在';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- ============================================================================
|
||||
-- 【步驟 2】創建索引(如果不存在)
|
||||
-- ============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest
|
||||
ON journal_entries(is_latest, entry_date);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_original_entry_id
|
||||
ON journal_entries(original_entry_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- 【步驟 3】驗證
|
||||
-- ============================================================================
|
||||
|
||||
SELECT 'Migration completed. Column info:' as status;
|
||||
|
||||
SELECT column_name, data_type, is_nullable
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries'
|
||||
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
|
||||
ORDER BY ordinal_position;
|
||||
92
backend/sql/migration_revision_tracking_manual.sql
Normal file
92
backend/sql/migration_revision_tracking_manual.sql
Normal file
@@ -0,0 +1,92 @@
|
||||
-- ============================================================================
|
||||
-- 【手動數據庫遷移腳本】版本追踪系統
|
||||
-- 如果自動遷移失敗,請由數據庫管理員手動執行此腳本
|
||||
-- ============================================================================
|
||||
|
||||
-- 【步驟 1】添加 is_latest 字段
|
||||
-- 用於標記該交易版本是否為最新版本(true = 最新,false = 已過時)
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN IF NOT EXISTS is_latest BOOLEAN DEFAULT true;
|
||||
|
||||
-- 【步驟 2】添加 revision_count 字段
|
||||
-- 用於追踪修正版本(1 = 原始,2 = 第一次修正,3 = 第二次修正,...)
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN IF NOT EXISTS revision_count INTEGER DEFAULT 1;
|
||||
|
||||
-- 【步驟 3】添加 original_entry_id 字段
|
||||
-- 用於指向原始交易 ID(NULL = 這是原始交易,>0 = 這是對ID的修正版本)
|
||||
ALTER TABLE journal_entries
|
||||
ADD COLUMN IF NOT EXISTS original_entry_id INTEGER;
|
||||
|
||||
-- 【步驟 4】創建性能索引
|
||||
-- 用於快速查詢最新版本的交易(試算表查詢會經常使用)
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_is_latest
|
||||
ON journal_entries(is_latest, entry_date DESC);
|
||||
|
||||
-- 【步驟 5】創建追踪索引
|
||||
-- 用於快速查詢指定交易的所有修正版本
|
||||
CREATE INDEX IF NOT EXISTS idx_journal_entries_original_id
|
||||
ON journal_entries(original_entry_id) WHERE original_entry_id IS NOT NULL;
|
||||
|
||||
-- ============================================================================
|
||||
-- 【驗證】
|
||||
-- ============================================================================
|
||||
-- 執行以下查詢確認字段已添加:
|
||||
SELECT column_name, data_type, column_default
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'journal_entries'
|
||||
AND column_name IN ('is_latest', 'revision_count', 'original_entry_id')
|
||||
ORDER BY ordinal_position DESC;
|
||||
|
||||
-- 查詢索引是否已創建:
|
||||
SELECT indexname, indexdef
|
||||
FROM pg_indexes
|
||||
WHERE tablename = 'journal_entries'
|
||||
AND indexname LIKE 'idx_journal_entries%';
|
||||
|
||||
-- ============================================================================
|
||||
-- 【可選】數據初始化
|
||||
-- ============================================================================
|
||||
-- 如果表中已有舊數據,執行以下操作以確保一致性:
|
||||
|
||||
-- 使用 begin;...commit; 作為事務邊界
|
||||
BEGIN;
|
||||
|
||||
-- 確保所有現有交易都被標記為最新版本
|
||||
UPDATE journal_entries
|
||||
SET is_latest = true
|
||||
WHERE is_latest IS NULL;
|
||||
|
||||
-- 確保所有現有交易的 revision_count 都至少為 1
|
||||
UPDATE journal_entries
|
||||
SET revision_count = 1
|
||||
WHERE revision_count IS NULL;
|
||||
|
||||
-- 提交更改
|
||||
COMMIT;
|
||||
|
||||
-- ============================================================================
|
||||
-- 【完成】
|
||||
-- ============================================================================
|
||||
-- 遷移完成!系統現在支持版本追踪。
|
||||
--
|
||||
-- 關鍵概念:
|
||||
-- - is_latest = true: 當前最新版本,應該在試算表和列表中顯示
|
||||
-- - is_latest = false: 已過時的版本,通常只在修正歷史中顯示
|
||||
-- - revision_count: 版本編號(1=原始,2=第一次修正,...)
|
||||
-- - original_entry_id: 指向原始交易,NULL 表示這是原始交易
|
||||
--
|
||||
-- 常見查詢:
|
||||
-- 1. 獲取所有最新交易(用於試算表):
|
||||
-- SELECT * FROM journal_entries WHERE is_latest = true AND is_deleted = false;
|
||||
--
|
||||
-- 2. 獲取指定交易的修正歷史:
|
||||
-- SELECT * FROM journal_entries
|
||||
-- WHERE journal_entry_id = 123 OR original_entry_id = 123
|
||||
-- ORDER BY revision_count ASC;
|
||||
--
|
||||
-- 3. 獲取修正後的版本:
|
||||
-- SELECT * FROM journal_entries
|
||||
-- WHERE original_entry_id IS NOT NULL AND is_latest = true;
|
||||
--
|
||||
-- ============================================================================
|
||||
98
backend/verify_correction.py
Normal file
98
backend/verify_correction.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Verify the correction logic is working correctly
|
||||
Check test entries (ID=431, 432)
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.core.database import get_connection
|
||||
|
||||
def verify():
|
||||
with get_connection() as conn:
|
||||
with conn.cursor() as cur:
|
||||
print("=" * 80)
|
||||
print("【修正ロジックの検証: test 元(ID=431、432)】")
|
||||
print("=" * 80)
|
||||
print()
|
||||
|
||||
print("【step 1: 修正チェーンの構造確認】")
|
||||
cur.execute("""
|
||||
SELECT
|
||||
journal_entry_id,
|
||||
description,
|
||||
is_latest,
|
||||
is_deleted,
|
||||
parent_entry_id,
|
||||
original_entry_id,
|
||||
entry_date,
|
||||
created_at,
|
||||
revision_count
|
||||
FROM journal_entries
|
||||
WHERE journal_entry_id IN (431, 432)
|
||||
ORDER BY journal_entry_id
|
||||
""")
|
||||
|
||||
entries = cur.fetchall()
|
||||
for e in entries:
|
||||
print(f"\nID={e['journal_entry_id']}:")
|
||||
print(f" 説明: {e['description']}")
|
||||
print(f" is_latest: {e['is_latest']} {'✓最新版' if e['is_latest'] else '✗旧版'}")
|
||||
print(f" is_deleted: {e['is_deleted']}")
|
||||
print(f" parent_entry_id: {e['parent_entry_id']}")
|
||||
print(f" original_entry_id: {e['original_entry_id']}")
|
||||
print(f" entry_date: {e['entry_date']}")
|
||||
print(f" created_at: {e['created_at']}")
|
||||
print(f" revision_count: {e['revision_count']}")
|
||||
|
||||
print()
|
||||
print("【Step 2: 各エントリの仕訳行詳細】")
|
||||
|
||||
for eid in [431, 432]:
|
||||
print(f"\nID={eid}:")
|
||||
cur.execute("""
|
||||
SELECT
|
||||
a.account_code,
|
||||
a.account_name,
|
||||
l.debit,
|
||||
l.credit
|
||||
FROM journal_lines l
|
||||
JOIN accounts a ON a.account_id = l.account_id
|
||||
WHERE l.journal_entry_id = %s
|
||||
ORDER BY a.account_code
|
||||
""", (eid,))
|
||||
|
||||
lines = cur.fetchall()
|
||||
for line in lines:
|
||||
print(f" {line['account_code']} {line['account_name']:<15s} | 借={line['debit']:>10.0f} 貸={line['credit']:>10.0f}")
|
||||
|
||||
print()
|
||||
print("【Step 3: is_latest=true フィルターでのみ統計】")
|
||||
cur.execute("""
|
||||
SELECT
|
||||
SUM(CASE WHEN l.account_id = (SELECT account_id FROM accounts WHERE account_code = '5') THEN l.debit ELSE 0 END) as debit_5,
|
||||
SUM(CASE WHEN l.account_id = (SELECT account_id FROM accounts WHERE account_code = '5') THEN l.credit ELSE 0 END) as credit_5,
|
||||
SUM(CASE WHEN l.account_id = (SELECT account_id FROM accounts WHERE account_code = '41') THEN l.debit ELSE 0 END) as debit_41,
|
||||
SUM(CASE WHEN l.account_id = (SELECT account_id FROM accounts WHERE account_code = '41') THEN l.credit ELSE 0 END) as credit_41
|
||||
FROM journal_lines l
|
||||
JOIN journal_entries j ON j.journal_entry_id = l.journal_entry_id
|
||||
WHERE (l.journal_entry_id = 431 OR l.journal_entry_id = 432)
|
||||
AND j.is_latest = true
|
||||
""")
|
||||
|
||||
result = cur.fetchone()
|
||||
print(f"売掛金(5): 借={result['debit_5']:>10.0f} 貸={result['credit_5']:>10.0f}")
|
||||
print(f"売上高(41): 借={result['debit_41']:>10.0f} 貸={result['credit_41']:>10.0f}")
|
||||
|
||||
print()
|
||||
print("✓ 検証完了")
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
verify()
|
||||
except Exception as e:
|
||||
print(f"エラー: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user