"""采购记录的只读 Qt Model/View 数据源。""" from __future__ import annotations from dataclasses import dataclass, field from typing import Protocol from PySide6.QtCore import QAbstractTableModel, QModelIndex, Qt from cmbuyer_client.logging_policy import redact_text from cmbuyer_client.core.errors import ValidationError from cmbuyer_client.core.validation import rfc3339_z_nanoseconds @dataclass(frozen=True) class PurchaseRecord: record_id: str title: str status: str created_at: str original_text: str = "" result_text: str = "" image_description: str = "" created_at_nanoseconds: int = field(init=False, repr=False) def __post_init__(self) -> None: # 记录 provider 只能注入可显示摘要;最终 UI 边界仍统一脱敏,避免 # consumer bug 把 Bearer/裸 token 放入 model、详情或可见日志。 for field in ("title", "status", "original_text", "result_text", "image_description"): object.__setattr__(self, field, redact_text(getattr(self, field))) if not isinstance(self.created_at, str): raise ValueError("noncanonical_record_timestamp") fraction = self.created_at[20:-1] if len(self.created_at) > 20 and self.created_at.endswith("Z") else "" if fraction and fraction.endswith("0"): raise ValueError("noncanonical_record_timestamp") try: timestamp = rfc3339_z_nanoseconds(self.created_at) except ValidationError: raise ValueError("noncanonical_record_timestamp") from None object.__setattr__(self, "created_at_nanoseconds", timestamp) class PurchaseRecordProvider(Protocol): """只返回已准备好的无秘密 View DTO;不得在 GUI 线程查询 SQLite/HTTP。""" def snapshot(self) -> list[PurchaseRecord]: ... class PurchaseRecordModel(QAbstractTableModel): RECORD_ID_ROLE = int(Qt.ItemDataRole.UserRole) + 1 def __init__(self, records: list[PurchaseRecord] | None = None, parent=None) -> None: super().__init__(parent) self._records: list[PurchaseRecord] = [] self.set_records(records or []) def rowCount(self, parent: QModelIndex = QModelIndex()) -> int: return 0 if parent.isValid() else len(self._records) def columnCount(self, parent: QModelIndex = QModelIndex()) -> int: return 0 if parent.isValid() else 2 def data(self, index: QModelIndex, role: int = int(Qt.ItemDataRole.DisplayRole)): if not index.isValid() or not 0 <= index.row() < len(self._records): return None record = self._records[index.row()] if role == int(Qt.ItemDataRole.DisplayRole): return record.title if index.column() == 0 else record.status if role == self.RECORD_ID_ROLE: return record.record_id if role == int(Qt.ItemDataRole.ToolTipRole): return f"{record.title}\n{record.created_at}" if role == int(Qt.ItemDataRole.TextAlignmentRole) and index.column() == 1: return int(Qt.AlignmentFlag.AlignCenter) return None def headerData(self, section: int, orientation: Qt.Orientation, role: int = int(Qt.ItemDataRole.DisplayRole)): if role != int(Qt.ItemDataRole.DisplayRole) or orientation != Qt.Orientation.Horizontal: return None return ("标题", "状态")[section] if 0 <= section < 2 else None def set_records(self, records: list[PurchaseRecord]) -> None: self.beginResetModel() self._records = sorted( records, key=lambda item: (item.created_at_nanoseconds, item.record_id), reverse=True, ) self.endResetModel() def record_at(self, row: int) -> PurchaseRecord | None: return self._records[row] if 0 <= row < len(self._records) else None def row_for_id(self, record_id: str) -> int: return next((row for row, item in enumerate(self._records) if item.record_id == record_id), -1)