電子帳簿保存法
This commit is contained in:
parent
97f2843620
commit
22c4a2e6b7
7 changed files with 487 additions and 20 deletions
|
|
@ -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
182
audit_export.py
Normal 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行の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()
|
||||
38
audit_export/README.md
Normal file
38
audit_export/README.md
Normal file
|
|
@ -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
|
||||
12
audit_export/audit_summary.json
Normal file
12
audit_export/audit_summary.json
Normal 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": []
|
||||
}
|
||||
}
|
||||
8
audit_export/invoices.jsonl
Normal file
8
audit_export/invoices.jsonl
Normal 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}}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]:
|
||||
|
|
|
|||
Loading…
Reference in a new issue