221 lines
7.3 KiB
Python
221 lines
7.3 KiB
Python
"""編集系画面で再利用するEditorフレームワーク(最小版)。"""
|
||
|
||
from dataclasses import dataclass
|
||
from typing import Callable, List, Optional
|
||
import flet as ft
|
||
from models.invoice_models import InvoiceItem, Product
|
||
|
||
|
||
@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),
|
||
]
|
||
)
|
||
)
|
||
|
||
list_control = ft.Column(data_rows, spacing=4)
|
||
return ft.Column(
|
||
[
|
||
header_row,
|
||
ft.Divider(height=1, color=ft.Colors.GREY_400),
|
||
ft.Container(content=list_control, height=250, padding=0),
|
||
]
|
||
)
|
||
|
||
|
||
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],
|
||
products: List[Product],
|
||
on_product_select: Callable[[int], None] | None = None,
|
||
row_refs: Optional[dict] = None,
|
||
) -> ft.Column:
|
||
"""編集モードの明細テーブル。"""
|
||
header_row = ft.Row(
|
||
[
|
||
ft.Text("商品", size=12, weight=ft.FontWeight.BOLD, width=180),
|
||
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: List[ft.Control] = []
|
||
for i, item in enumerate(items):
|
||
product_label = item.description if item.description else "商品選択"
|
||
product_field = ft.Button(
|
||
content=ft.Text(product_label, no_wrap=True),
|
||
width=180,
|
||
height=36,
|
||
on_click=(lambda _, idx=i: on_product_select(idx)) if on_product_select else None,
|
||
disabled=is_locked,
|
||
style=ft.ButtonStyle(
|
||
padding=ft.Padding.symmetric(horizontal=10, vertical=6),
|
||
bgcolor=ft.Colors.BLUE_GREY_100,
|
||
shape=ft.RoundedRectangleBorder(radius=4),
|
||
),
|
||
)
|
||
|
||
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),
|
||
)
|
||
|
||
subtotal_text = ft.Text(
|
||
f"¥{item.subtotal:,}",
|
||
size=12,
|
||
weight=ft.FontWeight.BOLD,
|
||
width=70,
|
||
text_align=ft.TextAlign.RIGHT,
|
||
)
|
||
|
||
if row_refs is not None:
|
||
row_refs[i] = {
|
||
"product": product_field,
|
||
"quantity": quantity_field,
|
||
"unit_price": unit_price_field,
|
||
"subtotal": subtotal_text,
|
||
}
|
||
|
||
data_rows.append(
|
||
ft.Row(
|
||
[
|
||
product_field,
|
||
quantity_field,
|
||
unit_price_field,
|
||
subtotal_text,
|
||
delete_button,
|
||
],
|
||
key=f"row-{i}-{item.description}",
|
||
)
|
||
)
|
||
|
||
list_control: ft.Control = ft.Column(data_rows, spacing=4)
|
||
|
||
return ft.Column(
|
||
[
|
||
header_row,
|
||
ft.Divider(height=1, color=ft.Colors.GREY_400),
|
||
ft.Container(
|
||
content=list_control,
|
||
height=250,
|
||
padding=0,
|
||
bgcolor=None,
|
||
border_radius=0,
|
||
expand=False,
|
||
),
|
||
]
|
||
)
|