h-1.flet.3/audit_export.py
2026-02-21 09:40:53 +09:00

182 lines
6.8 KiB
Python
Raw Permalink 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.

#!/usr/bin/env python3
"""
監査・移行用エクスポートツール
- SQLite DB から伝票単位で JSON Lines を出力
- ハッシュチェーン検証結果と説明文も出力
"""
import json
import sqlite3
import sys
from pathlib import Path
from datetime import datetime
from typing import List, Dict, Any
def export_invoices_as_jsonl(db_path: str, out_path: str) -> None:
"""DBから伝票を1行JSONでエクスポート"""
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
cur = conn.cursor()
cur.execute("""
SELECT
i.uuid,
i.document_type,
i.customer_name,
i.customer_address,
i.customer_phone,
i.amount,
i.tax,
i.total_amount,
i.date,
i.invoice_number,
i.notes,
i.node_id,
i.payload_json,
i.payload_hash,
i.prev_chain_hash,
i.chain_hash,
i.pdf_template_version,
i.company_info_version,
i.is_offset,
i.offset_target_uuid,
i.pdf_generated_at,
i.pdf_sha256,
GROUP_CONCAT(
json_object(
'description', ii.description,
'quantity', ii.quantity,
'unit_price', ii.unit_price,
'is_discount', ii.is_discount
), ','
) AS items_json
FROM invoices i
LEFT JOIN invoice_items ii ON i.id = ii.invoice_id
GROUP BY i.id
ORDER BY i.id ASC
""")
with open(out_path, "w", encoding="utf-8") as f:
for row in cur:
items = json.loads(row["items_json"] or "[]")
record = {
"uuid": row["uuid"],
"document_type": row["document_type"],
"customer": {
"name": row["customer_name"],
"address": row["customer_address"],
"phone": row["customer_phone"],
},
"amount": row["amount"],
"tax": row["tax"],
"total_amount": row["total_amount"],
"date": row["date"],
"invoice_number": row["invoice_number"],
"notes": row["notes"],
"node_id": row["node_id"],
"payload_json": json.loads(row["payload_json"]) if row["payload_json"] else None,
"payload_hash": row["payload_hash"],
"prev_chain_hash": row["prev_chain_hash"],
"chain_hash": row["chain_hash"],
"pdf_template_version": row["pdf_template_version"],
"company_info_version": row["company_info_version"],
"is_offset": bool(row["is_offset"]),
"offset_target_uuid": row["offset_target_uuid"],
"pdf_generated_at": row["pdf_generated_at"],
"pdf_sha256": row["pdf_sha256"],
"items": items,
}
f.write(json.dumps(record, ensure_ascii=False) + "\n")
conn.close()
def generate_audit_summary(db_path: str) -> Dict[str, Any]:
"""ハッシュチェーン検証とサマリーを返す"""
from services.repositories import InvoiceRepository
repo = InvoiceRepository(db_path)
result = repo.verify_chain()
# 件数
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM invoices")
total = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM invoices WHERE is_offset=1")
offsets = cur.fetchone()[0]
conn.close()
return {
"generated_at": datetime.now().isoformat(),
"db_path": db_path,
"total_invoices": total,
"offset_invoices": offsets,
"chain_verification": result,
}
def write_audit_readme(out_dir: Path) -> None:
"""監査用説明文READMEを出力"""
readme = """# 監査・移出パッケージ
## 概要
本パッケージは「販売アシスト1号」のSQLite DBからエクスポートした伝票データと、ハッシュチェーン検証結果を含みます。
## ファイル
- `invoices.jsonl` : 伝票1件ごとに1行のJSONpayload_json含む
- `audit_summary.json` : 全件数・赤伝件数・チェーン検証結果
- `README.md` : 本ファイル
## ハッシュチェーン方式(改ざん検知)
- 各伝票は `payload_json` を正規化JSONとし、その SHA256 を `payload_hash` として保持
- `chain_hash = SHA256(prev_chain_hash + ':' + payload_hash)` で直前伝票と連鎖
- 先頭伝票の `prev_chain_hash` は 64文字のゼロgenesis
- `node_id` はDB単位のUUIDで、端末故障時の復旧・識別に使用
## 検証手順
1. `audit_summary.json` の `chain_verification.ok` が `true` であること
2. `false` の場合は `chain_verification.errors` を確認
3. 任意:スクリプトで `invoices.jsonl` を読み込み、先頭から `chain_hash` を再計算して一致を確認
## 再現性PDF生成
- `payload_json` と `pdf_template_version`/`company_info_version` があれば、
同一内容のPDFを再生成可能PDFバイナリの完全一致は保証しない
- PDFは永続保存せず、必要時に再生成して利用・共有・削除する方針
## 赤伝(相殺)の扱い
- `is_offset=true` の伝票は赤伝(相殺伝票)
- `offset_target_uuid` で元伝票を指す
- 集計・表示ではデフォルト除外UIの「赤伝を表示」スイッチで切替
## 10年保管について
- SQLite DB本エクスポート元を正とし、10年間保管
- バックアップ・復旧はDBファイル単位で行うGoogle Drive等
- 必要に応じて本エクスポートパッケージを外部システムへ移行可能
---
生成日時: {generated_at}
""".format(generated_at=datetime.now().isoformat())
(out_dir / "README.md").write_text(readme, encoding="utf-8")
def main():
db_path = "sales.db"
out_dir = Path("audit_export")
out_dir.mkdir(exist_ok=True)
jsonl_path = out_dir / "invoices.jsonl"
summary_path = out_dir / "audit_summary.json"
print("Exporting invoices as JSONL...")
export_invoices_as_jsonl(db_path, str(jsonl_path))
print(f" -> {jsonl_path}")
print("Generating audit summary...")
summary = generate_audit_summary(db_path)
summary_path.write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(f" -> {summary_path}")
print("Writing README...")
write_audit_readme(out_dir)
print(f" -> {out_dir}/README.md")
print("\nAudit export complete.")
print(f"Total invoices: {summary['total_invoices']}")
print(f"Offset invoices: {summary['offset_invoices']}")
print(f"Chain verification OK: {summary['chain_verification']['ok']}")
if __name__ == "__main__":
main()