電子帳簿保存法

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.is_customer_picker_open = False
self.customer_search_query = "" self.customer_search_query = ""
self.show_offsets = False self.show_offsets = False
self.chain_verify_result = None
self.is_new_customer_form_open = False
# ビジネスロジックサービス # ビジネスロジックサービス
self.app_service = AppService() self.app_service = AppService()
@ -140,7 +142,9 @@ class FlutterStyleDashboard:
self.main_content.controls.clear() self.main_content.controls.clear()
if self.current_tab == 0: 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()) self.main_content.controls.append(self.create_customer_picker_screen())
else: else:
# 新規作成画面 # 新規作成画面
@ -208,6 +212,13 @@ class FlutterStyleDashboard:
[ [
ft.IconButton(ft.Icons.ARROW_BACK, on_click=back), ft.IconButton(ft.Icons.ARROW_BACK, on_click=back),
ft.Text("顧客を選択", size=18, weight=ft.FontWeight.BOLD), 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), padding=ft.padding.all(15),
@ -237,8 +248,8 @@ class FlutterStyleDashboard:
ft.Container( ft.Container(
content=ft.Button( content=ft.Button(
content=ft.Text(doc_type.value, size=12), content=ft.Text(doc_type.value, size=12),
bgcolor=ft.Colors.BLUE_GREY_800 if i == 0 else ft.Colors.GREY_300, bgcolor=ft.Colors.BLUE_GREY_800 if doc_type == self.selected_document_type else ft.Colors.GREY_300,
color=ft.Colors.WHITE if i == 0 else ft.Colors.BLACK, 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), on_click=lambda _, idx=i, dt=doc_type: self.select_document_type(dt.value),
width=100, width=100,
height=45, height=45,
@ -337,6 +348,11 @@ class FlutterStyleDashboard:
def on_toggle_offsets(e): def on_toggle_offsets(e):
self.show_offsets = bool(e.control.value) self.show_offsets = bool(e.control.value)
self.update_main_content() 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 = [] slip_cards = []
@ -351,6 +367,12 @@ class FlutterStyleDashboard:
content=ft.Row([ content=ft.Row([
ft.Text("📄 発行履歴管理", size=20, weight=ft.FontWeight.BOLD), ft.Text("📄 発行履歴管理", size=20, weight=ft.FontWeight.BOLD),
ft.Container(expand=True), ft.Container(expand=True),
ft.IconButton(
ft.Icons.VERIFIED,
tooltip="チェーン検証",
icon_color=ft.Colors.BLUE_300,
on_click=on_verify_chain,
),
ft.Row( ft.Row(
[ [
ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE), ft.Text("赤伝を表示", size=12, color=ft.Colors.WHITE),
@ -363,6 +385,12 @@ class FlutterStyleDashboard:
padding=ft.padding.all(15), padding=ft.padding.all(15),
bgcolor=ft.Colors.BLUE_GREY, 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( ft.Column(
@ -410,19 +438,49 @@ class FlutterStyleDashboard:
self.invoices = self.app_service.invoice.get_recent_invoices(20) self.invoices = self.app_service.invoice.get_recent_invoices(20)
self.update_main_content() 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 actions_row = None
if isinstance(slip, Invoice) and not getattr(slip, "is_offset", False): if isinstance(slip, Invoice):
actions_row = ft.Row( buttons = []
[ if not getattr(slip, "submitted_to_tax_authority", False):
buttons.append(
ft.IconButton( ft.IconButton(
ft.Icons.REPLAY_CIRCLE_FILLED, ft.Icons.REPLAY_CIRCLE_FILLED,
tooltip="赤伝(相殺)を発行", tooltip="赤伝(相殺)を発行",
icon_color=ft.Colors.RED_400, icon_color=ft.Colors.RED_400,
on_click=issue_offset, 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 display_amount = amount
if isinstance(slip, Invoice) and getattr(slip, "is_offset", False): if isinstance(slip, Invoice) and getattr(slip, "is_offset", False):
@ -458,6 +516,106 @@ class FlutterStyleDashboard:
elevation=3, 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): def open_customer_picker(self, e=None):
"""顧客選択を開く(画面内遷移)""" """顧客選択を開く(画面内遷移)"""
logging.info("顧客選択画面へ遷移") logging.info("顧客選択画面へ遷移")
@ -499,6 +657,7 @@ class FlutterStyleDashboard:
if dt.value == doc_type: if dt.value == doc_type:
self.selected_document_type = dt self.selected_document_type = dt
logging.info(f"帳票種類を選択: {doc_type}") logging.info(f"帳票種類を選択: {doc_type}")
self.update_main_content()
break break
def create_slip(self, e=None): 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", "company_info_version": "v1",
"is_offset": bool(getattr(invoice, "is_offset", False)), "is_offset": bool(getattr(invoice, "is_offset", False)),
"offset_target_uuid": getattr(invoice, "offset_target_uuid", None), "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) 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.chain_hash = chain_hash
invoice.pdf_template_version = "v1" invoice.pdf_template_version = "v1"
invoice.company_info_version = "v1" invoice.company_info_version = "v1"
invoice.submitted_to_tax_authority = False
def create_invoice(self, def create_invoice(self,
customer: Customer, customer: Customer,
@ -198,6 +200,21 @@ class InvoiceService:
logging.error(f"赤伝作成エラー: {e}") logging.error(f"赤伝作成エラー: {e}")
return None 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]: def get_recent_invoices(self, limit: int = 50) -> List[Invoice]:
"""最近の伝票を取得""" """最近の伝票を取得"""
return self.invoice_repo.get_all_invoices(limit) return self.invoice_repo.get_all_invoices(limit)
@ -250,11 +267,29 @@ class InvoiceService:
class CustomerService: class CustomerService:
"""顧客ビジネスロジック""" """顧客ビジネスロジック"""
def __init__(self): def __init__(self, db_path: str = "sales.db"):
self.customer_repo = CustomerRepository() self.db_path = db_path
self.customer_repo = CustomerRepository(db_path)
self._customer_cache: List[Customer] = [] self._customer_cache: List[Customer] = []
self._load_customers() 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): def _load_customers(self):
"""顧客データを読み込み""" """顧客データを読み込み"""
self._customer_cache = self.customer_repo.get_all_customers() 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", "offset_target_uuid", "offset_target_uuid TEXT")
ensure_column("invoices", "pdf_generated_at", "pdf_generated_at TEXT") ensure_column("invoices", "pdf_generated_at", "pdf_generated_at TEXT")
ensure_column("invoices", "pdf_sha256", "pdf_sha256 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() conn.commit()
@ -271,8 +272,8 @@ class InvoiceRepository:
payload_json, payload_hash, prev_chain_hash, chain_hash, payload_json, payload_hash, prev_chain_hash, chain_hash,
pdf_template_version, company_info_version, pdf_template_version, company_info_version,
is_offset, offset_target_uuid, is_offset, offset_target_uuid,
pdf_generated_at, pdf_sha256) pdf_generated_at, pdf_sha256, submitted_to_tax_authority)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', ( ''', (
invoice.uuid, invoice.uuid,
invoice.document_type.value, invoice.document_type.value,
@ -298,6 +299,7 @@ class InvoiceRepository:
getattr(invoice, "offset_target_uuid", None), getattr(invoice, "offset_target_uuid", None),
getattr(invoice, "pdf_generated_at", None), getattr(invoice, "pdf_generated_at", None),
getattr(invoice, "pdf_sha256", None), getattr(invoice, "pdf_sha256", None),
0 # submitted_to_tax_authority = false by default
)) ))
invoice_id = cursor.lastrowid invoice_id = cursor.lastrowid
@ -428,6 +430,7 @@ class InvoiceRepository:
inv.offset_target_uuid = row[22] inv.offset_target_uuid = row[22]
inv.pdf_generated_at = row[23] inv.pdf_generated_at = row[23]
inv.pdf_sha256 = row[24] inv.pdf_sha256 = row[24]
inv.submitted_to_tax_authority = bool(row[25])
except Exception: except Exception:
pass pass
@ -515,28 +518,58 @@ class CustomerRepository:
conn.commit() conn.commit()
def save_customer(self, customer: Customer) -> bool: 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: try:
with sqlite3.connect(self.db_path) as conn: with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute(''' cursor.execute("""
INSERT OR REPLACE INTO customers INSERT INTO customers
(id, name, formal_name, address, phone) (name, formal_name, address, phone, email)
VALUES (?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?)
''', ( """, (
customer.id,
customer.name, customer.name,
customer.formal_name, customer.formal_name,
customer.address, customer.address,
customer.phone customer.phone,
customer.email
)) ))
conn.commit() conn.commit()
return True return True
except Exception as e: 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 return False
def get_all_customers(self) -> List[Customer]: def get_all_customers(self) -> List[Customer]: