#!/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行のJSON(payload_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()