diff --git a/main.py b/main.py index f33337c..c2fb57c 100644 --- a/main.py +++ b/main.py @@ -222,6 +222,21 @@ class FlutterStyleDashboard: "amount_color": "#2F3C7E", "tag_text_color": "#4B4F67", "tag_bg": "#E7E9FB", + "draft_card_bg": "#F5EEE4", + "draft_border": "#D7C4AF", + "draft_badge_bg": "#A7743A", + "draft_shadow_highlight": ft.BoxShadow( + blur_radius=8, + spread_radius=0, + color="#FFFFFF", + offset=ft.Offset(-2, -2), + ), + "draft_shadow_depth": ft.BoxShadow( + blur_radius=14, + spread_radius=2, + color="#C3A88C", + offset=ft.Offset(4, 6), + ), "badge_bg": "#35C46B", } self.doc_type_palette = { @@ -443,24 +458,57 @@ class FlutterStyleDashboard: is_locked = getattr(self.editing_invoice, 'final_locked', False) is_view_mode = not getattr(self, 'is_detail_edit_mode', False) + is_draft = bool(getattr(self.editing_invoice, 'is_draft', False)) def set_doc_type(dt: DocumentType): self.select_document_type(dt) self.update_main_content() - doc_type_items = [ft.PopupMenuItem(content=ft.Text(dt.value), on_click=lambda _, d=dt: set_doc_type(d)) for dt in DocumentType] + doc_type_items = [ft.PopupMenuItem(content=ft.Text(dt.value), on_click=lambda _, d=dt: set_doc_type(d)) for dt in DocumentType if dt != DocumentType.DRAFT] + # ドラフト切替 + def toggle_draft(e): + self.editing_invoice.is_draft = bool(e.control.value) + self.update_main_content() + + draft_toggle = ft.PopupMenuItem( + content=ft.Row([ + ft.Text("下書き", size=12), + ft.Checkbox(value=is_draft, on_change=toggle_draft), + ], alignment=ft.MainAxisAlignment.SPACE_BETWEEN, vertical_alignment=ft.CrossAxisAlignment.CENTER), + ) + doc_type_items.insert(0, draft_toggle) + + draft_suffix = " (下書き)" if is_draft else "" + if not getattr(self, 'is_detail_edit_mode', False): - # 閲覧時は単なるテキスト表示 - title_ctrl = ft.Text(self.selected_document_type.value, size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_800) + title_ctrl = ft.Text( + f"{self.selected_document_type.value}{draft_suffix}", + size=18, + weight=ft.FontWeight.BOLD, + color=ft.Colors.BLUE_GREY_800, + ) doc_type_menu = None else: doc_type_menu = ft.PopupMenuButton( - content=ft.Text(self.selected_document_type.value, size=18, weight=ft.FontWeight.BOLD, color=ft.Colors.BLUE_GREY_800), + content=ft.Text( + f"{self.selected_document_type.value}{draft_suffix}", + size=18, + weight=ft.FontWeight.BOLD, + color=ft.Colors.BLUE_GREY_800, + ), items=doc_type_items, tooltip="帳票タイプ変更", ) title_ctrl = doc_type_menu + draft_badge = ft.Container( + content=ft.Text("下書き", size=11, weight=ft.FontWeight.BOLD, color=ft.Colors.BROWN_800), + padding=ft.Padding.symmetric(horizontal=8, vertical=4), + bgcolor=ft.Colors.BROWN_100, + border_radius=12, + visible=is_draft, + ) + app_bar = AppBar( title="伝票詳細", show_back=True, @@ -470,11 +518,15 @@ class FlutterStyleDashboard: action_icon=ft.Icons.SAVE if getattr(self, 'is_detail_edit_mode', False) else ft.Icons.EDIT, action_tooltip="保存" if getattr(self, 'is_detail_edit_mode', False) else "編集", bottom=None, - trailing_controls=[doc_type_menu] if doc_type_menu else [], + trailing_controls=([draft_badge] + ([doc_type_menu] if doc_type_menu else [])) if is_draft else ([doc_type_menu] if doc_type_menu else []), title_control=title_ctrl, ) body = self._create_edit_existing_screen() + body = ft.Container( + content=body, + bgcolor=ft.Colors.BROWN_50 if is_draft else None, + ) return ft.Column([ app_bar, @@ -745,6 +797,7 @@ class FlutterStyleDashboard: "document_type": invoice.document_type.value if getattr(invoice, "document_type", None) else None, "date": invoice.date.isoformat() if getattr(invoice, "date", None) else None, "notes": getattr(invoice, "notes", ""), + "is_draft": bool(getattr(invoice, "is_draft", False)), "customer": { "id": getattr(getattr(invoice, "customer", None), "id", None), "formal_name": getattr(getattr(invoice, "customer", None), "formal_name", None), @@ -777,6 +830,8 @@ class FlutterStyleDashboard: return True if snapshot.get("notes", "") != getattr(invoice, "notes", ""): return True + if snapshot.get("is_draft", False) != bool(getattr(invoice, "is_draft", False)): + return True snap_cust = snapshot.get("customer", {}) or {} cust = getattr(invoice, "customer", None) @@ -831,6 +886,7 @@ class FlutterStyleDashboard: first_item = description or "(明細なし)" extra_count = 0 + is_draft_card = isinstance(slip, Invoice) and bool(getattr(slip, "is_draft", False)) icon_bg = palette.get(slip_type, theme["icon_default_bg"]) display_amount = -abs(amount) if isinstance(slip, Invoice) and getattr(slip, "is_offset", False) else amount @@ -938,6 +994,28 @@ class FlutterStyleDashboard: status_chip = ft.Text("✓ LOCK", size=9, color=theme["tag_text_color"]) if final_locked else ft.Container() + if is_draft_card: + status_chip = ft.Row( + [ + ft.Container( + content=ft.Text("DRAFT", size=9, weight=ft.FontWeight.BOLD, color=ft.Colors.WHITE), + padding=ft.Padding.symmetric(horizontal=6, vertical=2), + bgcolor=theme["draft_badge_bg"], + border_radius=999, + ), + status_chip or ft.Container(), + ], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + card_bg = theme["draft_card_bg"] if is_draft_card else theme["card_bg"] + card_border = ft.Border.all(1, theme["draft_border"]) if is_draft_card else None + card_shadow = [ + theme["draft_shadow_highlight"], + theme["draft_shadow_depth"] + ] if is_draft_card else [theme["shadow"]] + card_body = ft.Container( content=ft.Column( [left_column, status_chip], @@ -945,9 +1023,10 @@ class FlutterStyleDashboard: expand=True, ), padding=ft.Padding.symmetric(horizontal=12, vertical=8), - bgcolor=theme["card_bg"], + bgcolor=card_bg, border_radius=theme["card_radius"], - shadow=[theme["shadow"]], + border=card_border, + shadow=card_shadow, ) return ft.GestureDetector( @@ -1129,11 +1208,12 @@ class FlutterStyleDashboard: customer=default_customer, date=datetime.now(), items=[], - document_type=DocumentType.DRAFT, + document_type=DocumentType.INVOICE, invoice_number="NEW-" + str(int(datetime.now().timestamp())) ) + self.editing_invoice.is_draft = True self.selected_customer = default_customer - self.selected_document_type = DocumentType.DRAFT + self.selected_document_type = DocumentType.INVOICE self.is_detail_edit_mode = True self.is_edit_mode = True self.is_customer_picker_open = False @@ -1161,6 +1241,7 @@ class FlutterStyleDashboard: """既存伝票の編集画面(新規・編集共通)""" # 編集不可チェック(新規作成時はFalse) is_new_invoice = self.editing_invoice.invoice_number.startswith("NEW-") + edit_bg = ft.Colors.BROWN_50 # LOCK条件:明示的に確定された場合のみLOCK # PDF生成だけではLOCKしない(お試しPDFを許可) @@ -1199,6 +1280,7 @@ class FlutterStyleDashboard: value=self.editing_invoice.customer.name if self.editing_invoice.customer.name != "選択してください" else "", disabled=is_locked, width=260, + bgcolor=edit_bg, ) def update_customer_name(e): @@ -1319,6 +1401,7 @@ class FlutterStyleDashboard: multiline=True, min_lines=2, max_lines=3, + bgcolor=edit_bg if not is_view_mode and not is_locked else None, ) def toggle_edit_mode(_): @@ -2297,7 +2380,6 @@ class FlutterStyleDashboard: self.selected_document_type = resolved_type logging.info(f"帳票種類を選択: {resolved_type.value}") - self.update_main_content() def create_slip(self, e=None): """伝票作成 - サービス層を使用""" diff --git a/services/repositories.py b/services/repositories.py index 486cd42..4bb6ae7 100644 --- a/services/repositories.py +++ b/services/repositories.py @@ -138,6 +138,7 @@ class InvoiceRepository: 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") + ensure_column("invoices", "is_draft", "is_draft INTEGER DEFAULT 0") # Explorer向けインデックス(大量データ閲覧時の検索性能を確保) cursor.execute("CREATE INDEX IF NOT EXISTS idx_invoices_date ON invoices(date)") @@ -292,8 +293,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, submitted_to_tax_authority) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + pdf_generated_at, pdf_sha256, submitted_to_tax_authority, is_draft) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ''', ( invoice.uuid, invoice.document_type.value, @@ -319,7 +320,8 @@ 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 + 0, # submitted_to_tax_authority = false by default + 1 if getattr(invoice, "is_draft", False) else 0 )) invoice_id = cursor.lastrowid @@ -355,7 +357,7 @@ class InvoiceRepository: cursor.execute(''' UPDATE invoices SET document_type = ?, customer_id = ?, customer_name = ?, customer_address = ?, customer_phone = ?, - date = ?, invoice_number = ?, notes = ?, pdf_generated_at = ?, updated_at = CURRENT_TIMESTAMP + date = ?, invoice_number = ?, notes = ?, pdf_generated_at = ?, is_draft = ?, updated_at = CURRENT_TIMESTAMP WHERE uuid = ? ''', ( invoice.document_type.value, @@ -367,6 +369,7 @@ class InvoiceRepository: invoice.invoice_number, invoice.notes, getattr(invoice, 'pdf_generated_at', None), + 1 if getattr(invoice, 'is_draft', False) else 0, invoice.uuid )) @@ -374,8 +377,9 @@ class InvoiceRepository: cursor.execute('SELECT id FROM invoices WHERE uuid = ?', (invoice.uuid,)) result = cursor.fetchone() if not result: - logging.error(f"伝票ID取得失敗: {invoice.uuid}") - return False + logging.warning(f"伝票ID取得失敗: {invoice.uuid} -> 新規保存にフォールバックします") + conn.rollback() + return self.save_invoice(invoice) invoice_id = result[0] # 明細を一度削除して再挿入 @@ -524,75 +528,72 @@ class InvoiceRepository: """DB行をInvoiceオブジェクトに変換""" try: invoice_id = row[0] - logging.info(f"変換開始: invoice_id={invoice_id}, row_length={len(row)}") - - # 明細取得 - cursor.execute(''' - SELECT description, quantity, unit_price, is_discount - FROM invoice_items - WHERE invoice_id = ? - ''', (invoice_id,)) - - item_rows = cursor.fetchall() + col_map = {desc[0]: idx for idx, desc in enumerate(cursor.description or [])} + + def val(name, default=None): + idx = col_map.get(name) + if idx is None or idx >= len(row): + return default + return row[idx] + + # 基本フィールド + doc_type_val = val("document_type", DocumentType.INVOICE.value) + doc_type = DocumentType(doc_type_val) if doc_type_val in DocumentType._value2member_map_ else DocumentType.INVOICE + date_str = val("date") or datetime.now().isoformat() + date_obj = datetime.fromisoformat(date_str) + + customer_name = val("customer_name", "") + customer = Customer( + id=val("customer_id", 0) or 0, + name=customer_name or "", + formal_name=customer_name or "", + address=val("customer_address", "") or "", + phone=val("customer_phone", "") or "", + ) + + is_draft = bool(val("is_draft", 0)) + + # 明細取得は別カーソルで行い、元カーソルのdescriptionを汚染しない + item_cursor = cursor.connection.cursor() + item_cursor.execute( + 'SELECT description, quantity, unit_price, is_discount, product_id FROM invoice_items WHERE invoice_id = ?', + (invoice_id,), + ) + item_rows = item_cursor.fetchall() items = [ InvoiceItem( description=ir[0], quantity=ir[1], unit_price=ir[2], - is_discount=bool(ir[3]) + is_discount=bool(ir[3]), ) for ir in item_rows ] - - # 顧客情報 - customer = Customer( - id=row[3] or 0, # customer_idフィールド - name=row[4], # customer_nameフィールド - formal_name=row[4], # customer_nameフィールド - address=row[5] or "", # customer_addressフィールド - phone=row[6] or "" # customer_phoneフィールド - ) - - # 伝票タイプ - doc_type = DocumentType.SALES - for dt in DocumentType: - if dt.value == row[2]: - doc_type = dt - break - - # 日付変換 - date_str = row[10] # 正しいdateフィールドのインデックス - logging.info(f"日付変換: {date_str}") - date_obj = datetime.fromisoformat(date_str) - + inv = Invoice( customer=customer, date=date_obj, items=items, - file_path=row[13], # 正しいfile_pathフィールドのインデックス - invoice_number=row[11] or "", # 正しいinvoice_numberフィールドのインデックス - notes=row[12], # 正しいnotesフィールドのインデックス + file_path=val("file_path"), + invoice_number=val("invoice_number", ""), + notes=val("notes", ""), document_type=doc_type, - uuid=row[1], + uuid=val("uuid"), + is_draft=is_draft, ) - # 監査用フィールド(存在していれば付与) - try: - inv.node_id = row[14] - inv.payload_json = row[15] - inv.payload_hash = row[16] - inv.prev_chain_hash = row[17] - inv.chain_hash = row[18] - inv.pdf_template_version = row[19] - inv.company_info_version = row[20] - inv.is_offset = bool(row[21]) - inv.offset_target_uuid = row[22] - inv.pdf_generated_at = row[23] - inv.pdf_sha256 = row[24] - inv.submitted_to_tax_authority = bool(row[27]) - except Exception: - pass - - logging.info(f"変換成功: {inv.invoice_number}") + # 監査・チェーン関連のフィールドは動的属性として保持 + inv.node_id = val("node_id") + inv.payload_json = val("payload_json") + inv.payload_hash = val("payload_hash") + inv.prev_chain_hash = val("prev_chain_hash") + inv.chain_hash = val("chain_hash") + inv.pdf_template_version = val("pdf_template_version") + inv.company_info_version = val("company_info_version") + inv.is_offset = bool(val("is_offset", 0)) + inv.offset_target_uuid = val("offset_target_uuid") + inv.pdf_generated_at = val("pdf_generated_at") + inv.pdf_sha256 = val("pdf_sha256") + inv.submitted_to_tax_authority = bool(val("submitted_to_tax_authority", 0)) return inv except Exception as e: