196 lines
6.4 KiB
Python
196 lines
6.4 KiB
Python
"""編集系画面で再利用するEditorフレームワーク(最小版)。"""
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Callable, List
|
||
|
||
import flet as ft
|
||
|
||
from models.invoice_models import InvoiceItem
|
||
|
||
|
||
@dataclass
|
||
class ValidationResult:
|
||
"""編集内容の検証結果。"""
|
||
|
||
ok: bool
|
||
errors: List[str]
|
||
|
||
|
||
def normalize_invoice_items(items: List[InvoiceItem]) -> List[InvoiceItem]:
|
||
"""保存前に明細を正規化(空行除去・数値補正)。"""
|
||
normalized: List[InvoiceItem] = []
|
||
|
||
for item in items:
|
||
description = (item.description or "").strip()
|
||
quantity = int(item.quantity or 0)
|
||
unit_price = int(item.unit_price or 0)
|
||
|
||
if not description and quantity == 0 and unit_price == 0:
|
||
continue
|
||
|
||
if quantity <= 0:
|
||
quantity = 1
|
||
|
||
normalized.append(
|
||
InvoiceItem(
|
||
description=description,
|
||
quantity=quantity,
|
||
unit_price=unit_price,
|
||
is_discount=bool(getattr(item, "is_discount", False)),
|
||
product_id=getattr(item, "product_id", None),
|
||
)
|
||
)
|
||
|
||
return normalized
|
||
|
||
|
||
def validate_invoice_items(items: List[InvoiceItem]) -> ValidationResult:
|
||
"""明細の最小バリデーション。"""
|
||
errors: List[str] = []
|
||
|
||
if not items:
|
||
errors.append("明細を1行以上入力してください")
|
||
return ValidationResult(ok=False, errors=errors)
|
||
|
||
for idx, item in enumerate(items, start=1):
|
||
if not (item.description or "").strip():
|
||
errors.append(f"{idx}行目: 商品名を入力してください")
|
||
if int(item.quantity or 0) <= 0:
|
||
errors.append(f"{idx}行目: 数量は1以上で入力してください")
|
||
if int(item.unit_price or 0) < 0:
|
||
errors.append(f"{idx}行目: 単価は0以上で入力してください")
|
||
|
||
return ValidationResult(ok=len(errors) == 0, errors=errors)
|
||
|
||
|
||
def build_invoice_items_view_table(items: List[InvoiceItem]) -> ft.Column:
|
||
"""表示モードの明細テーブル。"""
|
||
header_row = ft.Row(
|
||
[
|
||
ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True),
|
||
ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35),
|
||
ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70),
|
||
ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70),
|
||
ft.Container(width=35),
|
||
]
|
||
)
|
||
|
||
data_rows = []
|
||
for item in items:
|
||
data_rows.append(
|
||
ft.Row(
|
||
[
|
||
ft.Text(item.description, size=12, expand=True),
|
||
ft.Text(str(item.quantity), size=12, width=35, text_align=ft.TextAlign.RIGHT),
|
||
ft.Text(f"¥{item.unit_price:,}", size=12, width=70, text_align=ft.TextAlign.RIGHT),
|
||
ft.Text(
|
||
f"¥{item.subtotal:,}",
|
||
size=12,
|
||
weight=ft.FontWeight.BOLD,
|
||
width=70,
|
||
text_align=ft.TextAlign.RIGHT,
|
||
),
|
||
ft.Container(width=35),
|
||
]
|
||
)
|
||
)
|
||
|
||
return ft.Column(
|
||
[
|
||
header_row,
|
||
ft.Divider(height=1, color=ft.Colors.GREY_400),
|
||
ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250),
|
||
]
|
||
)
|
||
|
||
|
||
def build_invoice_items_edit_table(
|
||
items: List[InvoiceItem],
|
||
is_locked: bool,
|
||
on_update_field: Callable[[int, str, str], None],
|
||
on_delete_row: Callable[[int], None],
|
||
) -> ft.Column:
|
||
"""編集モードの明細テーブル。"""
|
||
header_row = ft.Row(
|
||
[
|
||
ft.Text("商品名", size=12, weight=ft.FontWeight.BOLD, expand=True),
|
||
ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35),
|
||
ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70),
|
||
ft.Text("小計", size=12, weight=ft.FontWeight.BOLD, width=70),
|
||
ft.Container(width=35),
|
||
]
|
||
)
|
||
|
||
data_rows = []
|
||
for i, item in enumerate(items):
|
||
product_field = ft.TextField(
|
||
value=item.description,
|
||
text_size=12,
|
||
height=28,
|
||
expand=True,
|
||
border=ft.border.all(1, ft.Colors.BLUE_200),
|
||
bgcolor=ft.Colors.WHITE,
|
||
content_padding=ft.padding.all(5),
|
||
on_change=lambda e, idx=i: on_update_field(idx, "description", e.control.value),
|
||
)
|
||
|
||
quantity_field = ft.TextField(
|
||
value=str(item.quantity),
|
||
text_size=12,
|
||
height=28,
|
||
width=35,
|
||
text_align=ft.TextAlign.RIGHT,
|
||
border=ft.border.all(1, ft.Colors.BLUE_200),
|
||
bgcolor=ft.Colors.WHITE,
|
||
content_padding=ft.padding.all(5),
|
||
on_change=lambda e, idx=i: on_update_field(idx, "quantity", e.control.value),
|
||
keyboard_type=ft.KeyboardType.NUMBER,
|
||
)
|
||
|
||
unit_price_field = ft.TextField(
|
||
value=f"{item.unit_price:,}",
|
||
text_size=12,
|
||
height=28,
|
||
width=70,
|
||
text_align=ft.TextAlign.RIGHT,
|
||
border=ft.border.all(1, ft.Colors.BLUE_200),
|
||
bgcolor=ft.Colors.WHITE,
|
||
content_padding=ft.padding.all(5),
|
||
on_change=lambda e, idx=i: on_update_field(idx, "unit_price", e.control.value.replace(",", "")),
|
||
keyboard_type=ft.KeyboardType.NUMBER,
|
||
)
|
||
|
||
delete_button = ft.IconButton(
|
||
ft.Icons.DELETE_OUTLINE,
|
||
tooltip="行を削除",
|
||
icon_color=ft.Colors.RED_600,
|
||
disabled=is_locked,
|
||
icon_size=16,
|
||
on_click=lambda _, idx=i: on_delete_row(idx),
|
||
)
|
||
|
||
data_rows.append(
|
||
ft.Row(
|
||
[
|
||
product_field,
|
||
quantity_field,
|
||
unit_price_field,
|
||
ft.Text(
|
||
f"¥{item.subtotal:,}",
|
||
size=12,
|
||
weight=ft.FontWeight.BOLD,
|
||
width=70,
|
||
text_align=ft.TextAlign.RIGHT,
|
||
),
|
||
delete_button,
|
||
]
|
||
)
|
||
)
|
||
|
||
return ft.Column(
|
||
[
|
||
header_row,
|
||
ft.Divider(height=1, color=ft.Colors.GREY_400),
|
||
ft.Column(data_rows, scroll=ft.ScrollMode.AUTO, height=250),
|
||
]
|
||
)
|