diff --git a/app_flutter_style_dashboard.py b/app_flutter_style_dashboard.py index c865381..21bf72e 100644 --- a/app_flutter_style_dashboard.py +++ b/app_flutter_style_dashboard.py @@ -32,6 +32,8 @@ class FlutterStyleDashboard: self.is_customer_picker_open = False self.customer_search_query = "" self.show_offsets = False + self.chain_verify_result = None + self.is_new_customer_form_open = False # ビジネスロジックサービス self.app_service = AppService() @@ -140,7 +142,9 @@ class FlutterStyleDashboard: self.main_content.controls.clear() if self.current_tab == 0: - if self.is_customer_picker_open: + if self.is_new_customer_form_open: + self.main_content.controls.append(self.create_new_customer_screen()) + elif self.is_customer_picker_open: self.main_content.controls.append(self.create_customer_picker_screen()) else: # 新規作成画面 @@ -208,6 +212,13 @@ class FlutterStyleDashboard: [ ft.IconButton(ft.Icons.ARROW_BACK, on_click=back), ft.Text("顧客を選択", size=18, weight=ft.FontWeight.BOLD), + ft.Container(expand=True), + ft.IconButton( + ft.Icons.PERSON_ADD, + tooltip="新規顧客追加", + icon_color=ft.Colors.WHITE, + on_click=lambda _: self.open_new_customer_form(), + ), ] ), padding=ft.padding.all(15), @@ -237,8 +248,8 @@ class FlutterStyleDashboard: ft.Container( content=ft.Button( content=ft.Text(doc_type.value, size=12), - bgcolor=ft.Colors.BLUE_GREY_800 if i == 0 else ft.Colors.GREY_300, - color=ft.Colors.WHITE if i == 0 else ft.Colors.BLACK, + bgcolor=ft.Colors.BLUE_GREY_800 if doc_type == self.selected_document_type else ft.Colors.GREY_300, + color=ft.Colors.WHITE if doc_type == self.selected_document_type else ft.Colors.BLACK, on_click=lambda _, idx=i, dt=doc_type: self.select_document_type(dt.value), width=100, height=45, @@ -337,6 +348,11 @@ class FlutterStyleDashboard: def on_toggle_offsets(e): self.show_offsets = bool(e.control.value) self.update_main_content() + + def on_verify_chain(e=None): + res = self.app_service.invoice.invoice_repo.verify_chain() + self.chain_verify_result = res + self.update_main_content() # 履歴カードリスト slip_cards = [] @@ -351,6 +367,12 @@ class FlutterStyleDashboard: content=ft.Row([ ft.Text("📄 発行履歴管理", size=20, weight=ft.FontWeight.BOLD), ft.Container(expand=True), + ft.IconButton( + ft.Icons.VERIFIED, + tooltip="チェーン検証", + icon_color=ft.Colors.BLUE_300, + on_click=on_verify_chain, + ), ft.Row( [ ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE), @@ -363,6 +385,12 @@ class FlutterStyleDashboard: padding=ft.padding.all(15), bgcolor=ft.Colors.BLUE_GREY, ), + + # 検証結果表示(あれば) + ft.Container( + content=self._build_chain_verify_result(), + margin=ft.Margin.only(bottom=10), + ) if self.chain_verify_result else ft.Container(height=0), # 履歴リスト ft.Column( @@ -410,19 +438,49 @@ class FlutterStyleDashboard: self.invoices = self.app_service.invoice.get_recent_invoices(20) self.update_main_content() + def regenerate_and_share(_=None): + if not isinstance(slip, Invoice): + return + pdf_path = self.app_service.invoice.regenerate_pdf(slip.uuid) + if pdf_path: + # TODO: OSの共有ダイアログを呼ぶ(プラットフォーム依存) + logging.info(f"PDF再生成: {pdf_path}(共有機能は未実装)") + # 共有後に削除(今は即削除) + self.app_service.invoice.delete_pdf_file(pdf_path) + logging.info(f"PDF削除完了: {pdf_path}") + else: + logging.error("PDF再生成失敗") + actions_row = None - if isinstance(slip, Invoice) and not getattr(slip, "is_offset", False): - actions_row = ft.Row( - [ + if isinstance(slip, Invoice): + buttons = [] + if not getattr(slip, "submitted_to_tax_authority", False): + buttons.append( ft.IconButton( ft.Icons.REPLAY_CIRCLE_FILLED, tooltip="赤伝(相殺)を発行", icon_color=ft.Colors.RED_400, on_click=issue_offset, ) - ], - alignment=ft.MainAxisAlignment.END, + ) + if not getattr(slip, "submitted_to_tax_authority", False): + buttons.append( + ft.IconButton( + ft.Icons.CHECK_CIRCLE, + tooltip="税務署提出済みに設定", + icon_color=ft.Colors.ORANGE_400, + on_click=lambda _: self.submit_invoice_for_tax(slip.uuid), + ) ) + buttons.append( + ft.IconButton( + ft.Icons.DOWNLOAD, + tooltip="PDF再生成→共有", + icon_color=ft.Colors.BLUE_400, + on_click=regenerate_and_share, + ) + ) + actions_row = ft.Row(buttons, alignment=ft.MainAxisAlignment.END) display_amount = amount if isinstance(slip, Invoice) and getattr(slip, "is_offset", False): @@ -458,6 +516,106 @@ class FlutterStyleDashboard: elevation=3, ) + def _build_chain_verify_result(self) -> ft.Control: + if not self.chain_verify_result: + return ft.Container(height=0) + r = self.chain_verify_result + ok = r.get("ok", False) + checked = r.get("checked", 0) + errors = r.get("errors", []) + if ok: + return ft.Container( + content=ft.Row([ + ft.Icon(ft.Icons.CHECK_CIRCLE, color=ft.Colors.GREEN, size=20), + ft.Text(f"チェーン検証 OK ({checked}件)", size=14, color=ft.Colors.GREEN), + ]), + bgcolor=ft.Colors.GREEN_50, + padding=ft.Padding.all(10), + border_radius=8, + ) + else: + return ft.Container( + content=ft.Column([ + ft.Row([ + ft.Icon(ft.Icons.ERROR, color=ft.Colors.RED, size=20), + ft.Text(f"チェーン検証 NG (checked={checked})", size=14, color=ft.Colors.RED), + ]), + ft.Text(f"エラー: {errors}", size=12, color=ft.Colors.RED_700), + ]), + bgcolor=ft.Colors.RED_50, + padding=ft.Padding.all(10), + border_radius=8, + ) + + def open_new_customer_form(self): + """新規顧客フォームを開く(画面内遷移)""" + self.is_new_customer_form_open = True + self.update_main_content() + + def create_new_customer_screen(self) -> ft.Container: + """新規顧客登録画面""" + name_field = ft.TextField(label="顧客名(略称)") + formal_name_field = ft.TextField(label="正式名称") + address_field = ft.TextField(label="住所") + phone_field = ft.TextField(label="電話番号") + + def save_customer(_): + name = (name_field.value or "").strip() + formal_name = (formal_name_field.value or "").strip() + address = (address_field.value or "").strip() + phone = (phone_field.value or "").strip() + if not name or not formal_name: + # TODO: エラー表示 + return + new_customer = self.app_service.customer.create_customer(name, formal_name, address, phone) + if new_customer: + self.customers = self.app_service.customer.get_all_customers() + self.selected_customer = new_customer + logging.info(f"新規顧客登録: {new_customer.formal_name}") + self.is_customer_picker_open = False + self.is_new_customer_form_open = False + self.update_main_content() + else: + logging.error("新規顧客登録失敗") + + def cancel(_): + self.is_new_customer_form_open = False + self.update_main_content() + + return ft.Container( + content=ft.Column([ + ft.Container( + content=ft.Row([ + ft.IconButton(ft.Icons.ARROW_BACK, on_click=cancel), + ft.Text("新規顧客登録", size=18, weight=ft.FontWeight.BOLD), + ]), + padding=ft.padding.all(15), + bgcolor=ft.Colors.BLUE_GREY, + ), + ft.Container( + content=ft.Column([ + ft.Text("顧客情報を入力", size=16, weight=ft.FontWeight.BOLD), + ft.Container(height=10), + name_field, + ft.Container(height=10), + formal_name_field, + ft.Container(height=10), + address_field, + ft.Container(height=10), + phone_field, + ft.Container(height=20), + ft.Row([ + ft.Button("保存", on_click=save_customer, bgcolor=ft.Colors.BLUE_GREY_800, color=ft.Colors.WHITE), + ft.Button("キャンセル", on_click=cancel), + ], spacing=10), + ]), + padding=ft.padding.all(20), + expand=True, + ), + ]), + expand=True, + ) + def open_customer_picker(self, e=None): """顧客選択を開く(画面内遷移)""" logging.info("顧客選択画面へ遷移") @@ -499,6 +657,7 @@ class FlutterStyleDashboard: if dt.value == doc_type: self.selected_document_type = dt logging.info(f"帳票種類を選択: {doc_type}") + self.update_main_content() break def create_slip(self, e=None): diff --git a/audit_export.py b/audit_export.py new file mode 100644 index 0000000..8df1893 --- /dev/null +++ b/audit_export.py @@ -0,0 +1,182 @@ +#!/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() diff --git a/audit_export/README.md b/audit_export/README.md new file mode 100644 index 0000000..db1d3e5 --- /dev/null +++ b/audit_export/README.md @@ -0,0 +1,38 @@ +# 監査・移出パッケージ + +## 概要 +本パッケージは「販売アシスト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等) +- 必要に応じて本エクスポートパッケージを外部システムへ移行可能 + +--- +生成日時: 2026-02-21T07:57:34.454286 diff --git a/audit_export/audit_summary.json b/audit_export/audit_summary.json new file mode 100644 index 0000000..4fc8c92 --- /dev/null +++ b/audit_export/audit_summary.json @@ -0,0 +1,12 @@ +{ + "generated_at": "2026-02-21T07:57:34.453267", + "db_path": "sales.db", + "total_invoices": 8, + "offset_invoices": 1, + "chain_verification": { + "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", + "checked": 4, + "ok": true, + "errors": [] + } +} \ No newline at end of file diff --git a/audit_export/invoices.jsonl b/audit_export/invoices.jsonl new file mode 100644 index 0000000..5d55bd9 --- /dev/null +++ b/audit_export/invoices.jsonl @@ -0,0 +1,8 @@ +{"uuid": "113446a2-8ec8-455c-94d9-992d648c55dd", "document_type": "請求書", "customer": {"name": "田中商事株式会社", "address": "東京都千代田区丸の内1-1-1", "phone": "03-1234-5678"}, "amount": 250000.0, "tax": 25000.0, "total_amount": 275000.0, "date": "2026-02-20T21:07:43.446149", "invoice_number": "20260220-2107", "notes": "テスト伝票", "node_id": null, "payload_json": null, "payload_hash": null, "prev_chain_hash": null, "chain_hash": null, "pdf_template_version": null, "company_info_version": null, "is_offset": false, "offset_target_uuid": null, "pdf_generated_at": null, "pdf_sha256": null, "items": {"description": "請求書分", "quantity": 1, "unit_price": 250000, "is_discount": 0}} +{"uuid": "eaf13503-667d-4548-b3ff-16d1108b898f", "document_type": "売上伝票", "customer": {"name": "佐藤工業株式会社", "address": "東京都品川区東品川1-1-1", "phone": "03-3456-7890"}, "amount": 250000.0, "tax": 25000.0, "total_amount": 275000.0, "date": "2026-02-20T21:36:46.676752", "invoice_number": "20260220-2136", "notes": "", "node_id": null, "payload_json": null, "payload_hash": null, "prev_chain_hash": null, "chain_hash": null, "pdf_template_version": null, "company_info_version": null, "is_offset": false, "offset_target_uuid": null, "pdf_generated_at": null, "pdf_sha256": null, "items": {"description": "売上伝票分", "quantity": 1, "unit_price": 250000, "is_discount": 0}} +{"uuid": "7face002-113c-45ad-a89c-a73082390cf6", "document_type": "請求書", "customer": {"name": "佐藤工業株式会社", "address": "東京都品川区東品川1-1-1", "phone": "03-3456-7890"}, "amount": 250000.0, "tax": 25000.0, "total_amount": 275000.0, "date": "2026-02-20T21:39:44.015079", "invoice_number": "20260220-2139", "notes": "", "node_id": null, "payload_json": null, "payload_hash": null, "prev_chain_hash": null, "chain_hash": null, "pdf_template_version": null, "company_info_version": null, "is_offset": false, "offset_target_uuid": null, "pdf_generated_at": null, "pdf_sha256": null, "items": {"description": "請求書分", "quantity": 1, "unit_price": 250000, "is_discount": 0}} +{"uuid": "3772e7ed-19be-4049-ac62-025c725d0912", "document_type": "請求書", "customer": {"name": "佐藤工業株式会社", "address": "東京都品川区東品川1-1-1", "phone": "03-3456-7890"}, "amount": 250000.0, "tax": 25000.0, "total_amount": 275000.0, "date": "2026-02-20T21:41:09.712103", "invoice_number": "20260220-2141", "notes": "", "node_id": null, "payload_json": null, "payload_hash": null, "prev_chain_hash": null, "chain_hash": null, "pdf_template_version": null, "company_info_version": null, "is_offset": false, "offset_target_uuid": null, "pdf_generated_at": null, "pdf_sha256": null, "items": {"description": "請求書分", "quantity": 1, "unit_price": 250000, "is_discount": 0}} +{"uuid": "51b9ef4e-377c-4515-a263-1855cb2cb423", "document_type": "請求書", "customer": {"name": "佐藤工業株式会社", "address": "東京都品川区東品川1-1-1", "phone": "03-3456-7890"}, "amount": 12345.0, "tax": 1234.0, "total_amount": 13579.0, "date": "2026-02-20T22:13:38.109736", "invoice_number": "20260220-2213", "notes": "hash test", "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "payload_json": {"company_info_version": "v1", "customer_master_id": 3, "customer_snapshot": {"address": "東京都品川区東品川1-1-1", "formal_name": "佐藤工業株式会社", "name": "佐藤工業", "phone": "03-3456-7890"}, "date": "2026-02-20T22:13:38", "document_type": "請求書", "invoice_number": "20260220-2213", "items": [{"description": "請求書分", "is_discount": false, "quantity": 1, "unit_price": 12345}], "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "notes": "hash test", "pdf_template_version": "v1", "schema": "invoice_payload_v1", "tax_calc_rule": "floor(subtotal * tax_rate)", "tax_rate": 0.1, "uuid": "51b9ef4e-377c-4515-a263-1855cb2cb423"}, "payload_hash": "4cedce41aba36c7f3273e39954dbc1d7ce51d9d9ed3a929496933846d897bffc", "prev_chain_hash": "0000000000000000000000000000000000000000000000000000000000000000", "chain_hash": "9262891799ab99d6fedbdd9a4a410176e204d6f42901f4d48263b875e9874a9e", "pdf_template_version": "v1", "company_info_version": "v1", "is_offset": false, "offset_target_uuid": null, "pdf_generated_at": "2026-02-20T22:13:38", "pdf_sha256": null, "items": {"description": "請求書分", "quantity": 1, "unit_price": 12345, "is_discount": 0}} +{"uuid": "8f62815c-14bc-4d4b-9cb0-777c0df92870", "document_type": "請求書", "customer": {"name": "佐藤工業株式会社", "address": "東京都品川区東品川1-1-1", "phone": "03-3456-7890"}, "amount": 111.0, "tax": 11.0, "total_amount": 122.0, "date": "2026-02-20T22:26:53.777941", "invoice_number": "20260220-2226", "notes": "ephemeral pdf", "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "payload_json": {"company_info_version": "v1", "customer_master_id": 3, "customer_snapshot": {"address": "東京都品川区東品川1-1-1", "formal_name": "佐藤工業株式会社", "name": "佐藤工業", "phone": "03-3456-7890"}, "date": "2026-02-20T22:26:53", "document_type": "請求書", "invoice_number": "20260220-2226", "items": [{"description": "請求書分", "is_discount": false, "quantity": 1, "unit_price": 111}], "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "notes": "ephemeral pdf", "pdf_template_version": "v1", "schema": "invoice_payload_v1", "tax_calc_rule": "floor(subtotal * tax_rate)", "tax_rate": 0.1, "uuid": "8f62815c-14bc-4d4b-9cb0-777c0df92870"}, "payload_hash": "805dbaf93eb5b04db46e3ebead91e79d1bb754272fcbf3fc3ed842260e78e169", "prev_chain_hash": "9262891799ab99d6fedbdd9a4a410176e204d6f42901f4d48263b875e9874a9e", "chain_hash": "7aca5d565b5dff904d8f056f75b82e3b8d9fc1d5dc5f611571d716255eaefad6", "pdf_template_version": "v1", "company_info_version": "v1", "is_offset": false, "offset_target_uuid": null, "pdf_generated_at": null, "pdf_sha256": null, "items": {"description": "請求書分", "quantity": 1, "unit_price": 111, "is_discount": 0}} +{"uuid": "61f634cf-cf01-445e-8388-20b5a7fedcd9", "document_type": "請求書", "customer": {"name": "東京都品川区東品川1-1-1", "address": "03-3456-7890", "phone": "250000.0"}, "amount": -250000.0, "tax": -25000.0, "total_amount": -275000.0, "date": "2026-02-20T22:36:01.576234", "invoice_number": "20260220-2236", "notes": "", "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "payload_json": {"company_info_version": "v1", "customer_master_id": "佐藤工業株式会社", "customer_snapshot": {"address": "03-3456-7890", "formal_name": "東京都品川区東品川1-1-1", "name": "東京都品川区東品川1-1-1", "phone": 250000.0}, "date": "2026-02-20T22:36:01", "document_type": "請求書", "invoice_number": "20260220-2236", "is_offset": true, "items": [{"description": "相殺(赤伝) 対象:20260220-2141", "is_discount": true, "quantity": 1, "unit_price": 250000}], "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "notes": "", "offset_target_uuid": "3772e7ed-19be-4049-ac62-025c725d0912", "pdf_template_version": "v1", "schema": "invoice_payload_v1", "tax_calc_rule": "floor(subtotal * tax_rate)", "tax_rate": 0.1, "uuid": "61f634cf-cf01-445e-8388-20b5a7fedcd9"}, "payload_hash": "b6c6fa69eca77964dd3957484f88382fc21c57d971a2bf9d7f470493506fb399", "prev_chain_hash": "7aca5d565b5dff904d8f056f75b82e3b8d9fc1d5dc5f611571d716255eaefad6", "chain_hash": "be7d57eeca53eae26e234536678822e1b6e09396aa8b1f41a3aee8d0d1b0b291", "pdf_template_version": "v1", "company_info_version": "v1", "is_offset": true, "offset_target_uuid": "3772e7ed-19be-4049-ac62-025c725d0912", "pdf_generated_at": null, "pdf_sha256": null, "items": {"description": "相殺(赤伝) 対象:20260220-2141", "quantity": 1, "unit_price": 250000, "is_discount": 1}} +{"uuid": "7c8ba2a0-f478-4ffc-ac7b-bac1984b8286", "document_type": "見積書", "customer": {"name": "佐藤工業株式会社", "address": "東京都品川区東品川1-1-1", "phone": "03-3456-7890"}, "amount": 1.0, "tax": 0.0, "total_amount": 1.0, "date": "2026-02-20T22:37:37.543579", "invoice_number": "20260220-2237", "notes": "", "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "payload_json": {"company_info_version": "v1", "customer_master_id": 3, "customer_snapshot": {"address": "東京都品川区東品川1-1-1", "formal_name": "佐藤工業株式会社", "name": "佐藤工業", "phone": "03-3456-7890"}, "date": "2026-02-20T22:37:37", "document_type": "見積書", "invoice_number": "20260220-2237", "is_offset": false, "items": [{"description": "見積書分", "is_discount": false, "quantity": 1, "unit_price": 1}], "node_id": "662182dc-8024-43ee-bba5-7072917b95fe", "notes": "", "offset_target_uuid": null, "pdf_template_version": "v1", "schema": "invoice_payload_v1", "tax_calc_rule": "floor(subtotal * tax_rate)", "tax_rate": 0.1, "uuid": "7c8ba2a0-f478-4ffc-ac7b-bac1984b8286"}, "payload_hash": "658cd041f84a372c8d2f5817f5172b61f8a138655197644ac527a296a43ef567", "prev_chain_hash": "be7d57eeca53eae26e234536678822e1b6e09396aa8b1f41a3aee8d0d1b0b291", "chain_hash": "6963cfb33ea008b1f1f1ee8dc6ce930fa634cf51bb7be94b793e5ef6752cccbd", "pdf_template_version": "v1", "company_info_version": "v1", "is_offset": false, "offset_target_uuid": null, "pdf_generated_at": null, "pdf_sha256": null, "items": {"description": "見積書分", "quantity": 1, "unit_price": 1, "is_discount": 0}} diff --git a/services/app_service.py b/services/app_service.py index eb041f5..b4a1ba9 100644 --- a/services/app_service.py +++ b/services/app_service.py @@ -71,6 +71,7 @@ class InvoiceService: "company_info_version": "v1", "is_offset": bool(getattr(invoice, "is_offset", False)), "offset_target_uuid": getattr(invoice, "offset_target_uuid", None), + "submitted_to_tax_authority": getattr(invoice, "submitted_to_tax_authority", False), } payload_json = json.dumps(payload_obj, ensure_ascii=False, separators=(",", ":"), sort_keys=True) @@ -84,6 +85,7 @@ class InvoiceService: invoice.chain_hash = chain_hash invoice.pdf_template_version = "v1" invoice.company_info_version = "v1" + invoice.submitted_to_tax_authority = False def create_invoice(self, customer: Customer, @@ -198,6 +200,21 @@ class InvoiceService: logging.error(f"赤伝作成エラー: {e}") return None + def submit_to_tax_authority(self, invoice_uuid: str) -> bool: + """税務署提出済みフラグを設定し、ロックする""" + try: + with sqlite3.connect(self.invoice_repo.db_path) as conn: + cursor = conn.cursor() + cursor.execute( + "UPDATE invoices SET submitted_to_tax_authority = 1 WHERE uuid = ?", + (invoice_uuid,) + ) + conn.commit() + return True + except Exception as e: + logging.error(f"税務署提出エラー: {e}") + return False + def get_recent_invoices(self, limit: int = 50) -> List[Invoice]: """最近の伝票を取得""" return self.invoice_repo.get_all_invoices(limit) @@ -250,11 +267,29 @@ class InvoiceService: class CustomerService: """顧客ビジネスロジック""" - def __init__(self): - self.customer_repo = CustomerRepository() + def __init__(self, db_path: str = "sales.db"): + self.db_path = db_path + self.customer_repo = CustomerRepository(db_path) self._customer_cache: List[Customer] = [] self._load_customers() + def create_customer(self, name: str, formal_name: str, address: str = "", phone: str = "") -> Customer: + """顧客を新規作成""" + customer = Customer( + id=0, # 新規はID=0 + name=name, + formal_name=formal_name, + address=address, + phone=phone + ) + success = self.customer_repo.save_customer(customer) + if success: + self._customer_cache.append(customer) + logging.info(f"新規顧客登録: {formal_name}") + else: + logging.error(f"新規顧客登録失敗: {formal_name}") + return customer if success else None + def _load_customers(self): """顧客データを読み込み""" self._customer_cache = self.customer_repo.get_all_customers() diff --git a/services/repositories.py b/services/repositories.py index f17760f..763abec 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -124,6 +124,7 @@ class InvoiceRepository: ensure_column("invoices", "offset_target_uuid", "offset_target_uuid TEXT") ensure_column("invoices", "pdf_generated_at", "pdf_generated_at TEXT") ensure_column("invoices", "pdf_sha256", "pdf_sha256 TEXT") + ensure_column("invoices", "submitted_to_tax_authority", "submitted_to_tax_authority INTEGER DEFAULT 0") conn.commit() @@ -271,8 +272,8 @@ class InvoiceRepository: payload_json, payload_hash, prev_chain_hash, chain_hash, pdf_template_version, company_info_version, is_offset, offset_target_uuid, - pdf_generated_at, pdf_sha256) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + pdf_generated_at, pdf_sha256, submitted_to_tax_authority) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( invoice.uuid, invoice.document_type.value, @@ -298,6 +299,7 @@ class InvoiceRepository: getattr(invoice, "offset_target_uuid", None), getattr(invoice, "pdf_generated_at", None), getattr(invoice, "pdf_sha256", None), + 0 # submitted_to_tax_authority = false by default )) invoice_id = cursor.lastrowid @@ -428,6 +430,7 @@ class InvoiceRepository: inv.offset_target_uuid = row[22] inv.pdf_generated_at = row[23] inv.pdf_sha256 = row[24] + inv.submitted_to_tax_authority = bool(row[25]) except Exception: pass @@ -515,28 +518,58 @@ class CustomerRepository: conn.commit() def save_customer(self, customer: Customer) -> bool: - """顧客を保存""" + """顧客を保存(新規登録・更新共通)""" + # IDが0の場合は新規登録、それ以外は更新 + if customer.id == 0: + return self._insert_customer(customer) + else: + return self.update_customer(customer) + + def _insert_customer(self, customer: Customer) -> bool: + """顧客を新規登録""" try: with sqlite3.connect(self.db_path) as conn: cursor = conn.cursor() - cursor.execute(''' - INSERT OR REPLACE INTO customers - (id, name, formal_name, address, phone) + cursor.execute(""" + INSERT INTO customers + (name, formal_name, address, phone, email) VALUES (?, ?, ?, ?, ?) - ''', ( - customer.id, + """, ( customer.name, customer.formal_name, customer.address, - customer.phone + customer.phone, + customer.email )) conn.commit() return True except Exception as e: - logging.error(f"顧客保存エラー: {e}") + logging.error(f"顧客新規登録エラー: {e}") + return False + + def update_customer(self, customer: Customer) -> bool: + """顧客情報を更新""" + try: + with sqlite3.connect(self.db_path) as conn: + cursor = conn.cursor() + cursor.execute(""" + UPDATE customers + SET name = ?, formal_name = ?, address = ?, phone = ? + WHERE id = ? + """, ( + customer.name, + customer.formal_name, + customer.address, + customer.phone, + customer.id + )) + conn.commit() + return True + except Exception as e: + logging.error(f"顧客更新エラー: {e}") return False def get_all_customers(self) -> List[Customer]: