電子帳簿保存法

This commit is contained in:
joe 2026-02-21 09:40:53 +09:00
parent 97f2843620
commit 22c4a2e6b7
7 changed files with 487 additions and 20 deletions

View file

@ -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,
@ -338,6 +349,11 @@ class FlutterStyleDashboard:
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 = []
for slip in slips:
@ -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),
@ -364,6 +386,12 @@ class FlutterStyleDashboard:
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(
controls=slip_cards,
@ -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):

182
audit_export.py Normal file
View file

@ -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行の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()

38
audit_export/README.md Normal file
View file

@ -0,0 +1,38 @@
# 監査・移出パッケージ
## 概要
本パッケージは「販売アシスト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等
- 必要に応じて本エクスポートパッケージを外部システムへ移行可能
---
生成日時: 2026-02-21T07:57:34.454286

View file

@ -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": []
}
}

View file

@ -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}}

View file

@ -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()

View file

@ -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]: