140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
"""Local label history store.
|
|
|
|
Since the Stamps SERA API has no list-labels endpoint, we persist every
|
|
created label to appdata/label_history.json at print time. The history
|
|
window reads from here rather than polling the API.
|
|
|
|
The void/refund and SCAN form actions DO hit the API, using the label_id
|
|
stored here.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from dataclasses import asdict, dataclass
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class LabelEntry:
|
|
label_id: str
|
|
tracking_number: str
|
|
date_printed: str # ISO 8601
|
|
recipient: str # name / first line of address block
|
|
service: str # display name, e.g. "Ground Advantage"
|
|
package_type: str
|
|
amount: float | None
|
|
notes: str # invoice numbers / free-text notes
|
|
address_block: str
|
|
voided: bool = False
|
|
shipment_status: str = ""
|
|
date_delivered: str | None = None
|
|
carrier: str = "" # "usps" | "fedex"
|
|
|
|
@property
|
|
def date_printed_display(self) -> str:
|
|
try:
|
|
return datetime.fromisoformat(self.date_printed).strftime("%m/%d/%Y")
|
|
except Exception:
|
|
return self.date_printed
|
|
|
|
@property
|
|
def date_delivered_display(self) -> str:
|
|
if not self.date_delivered:
|
|
return ""
|
|
try:
|
|
return datetime.fromisoformat(self.date_delivered).strftime("%m/%d/%Y")
|
|
except Exception:
|
|
return self.date_delivered
|
|
|
|
|
|
class LabelHistory:
|
|
"""Load / save / query the local label history JSON file."""
|
|
|
|
def __init__(self) -> None:
|
|
from .paths import appdata_dir
|
|
self._path: Path = appdata_dir() / "label_history.json"
|
|
self._entries: list[LabelEntry] = self._load()
|
|
|
|
# ── Persistence ────────────────────────────────────────────────────────
|
|
|
|
def _load(self) -> list[LabelEntry]:
|
|
try:
|
|
if self._path.exists():
|
|
raw = json.loads(self._path.read_text(encoding="utf-8"))
|
|
return [LabelEntry(**r) for r in raw]
|
|
except Exception as exc:
|
|
log.warning("Could not load label history: %s", exc)
|
|
return []
|
|
|
|
def _save(self) -> None:
|
|
try:
|
|
data = [asdict(e) for e in self._entries]
|
|
self._path.write_text(
|
|
json.dumps(data, indent=2, default=str),
|
|
encoding="utf-8",
|
|
)
|
|
except Exception as exc:
|
|
log.warning("Could not save label history: %s", exc)
|
|
|
|
# ── Mutation ───────────────────────────────────────────────────────────
|
|
|
|
def add(self, entry: LabelEntry) -> None:
|
|
self._entries.insert(0, entry)
|
|
self._save()
|
|
|
|
def mark_voided(self, label_id: str) -> None:
|
|
for e in self._entries:
|
|
if e.label_id == label_id:
|
|
e.voided = True
|
|
e.shipment_status = "Voided"
|
|
self._save()
|
|
|
|
def update_status(self, label_id: str, status: str,
|
|
date_delivered: str | None = None) -> None:
|
|
for e in self._entries:
|
|
if e.label_id == label_id:
|
|
e.shipment_status = status
|
|
if date_delivered:
|
|
e.date_delivered = date_delivered
|
|
self._save()
|
|
|
|
# ── Query ──────────────────────────────────────────────────────────────
|
|
|
|
def all(self) -> list[LabelEntry]:
|
|
return list(self._entries)
|
|
|
|
def filter(
|
|
self,
|
|
search: str = "",
|
|
status: str = "All",
|
|
days: int | None = None,
|
|
carrier: str = "",
|
|
) -> list[LabelEntry]:
|
|
results = self._entries
|
|
if carrier:
|
|
results = [e for e in results if e.carrier == carrier]
|
|
if days is not None:
|
|
from datetime import timedelta, timezone
|
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat()
|
|
results = [e for e in results if e.date_printed >= cutoff]
|
|
if status != "All":
|
|
if status == "Voided":
|
|
results = [e for e in results if e.voided]
|
|
else:
|
|
results = [e for e in results
|
|
if not e.voided and e.shipment_status == status]
|
|
if search:
|
|
q = search.lower()
|
|
results = [
|
|
e for e in results
|
|
if q in e.recipient.lower()
|
|
or q in e.notes.lower()
|
|
or q in e.tracking_number.lower()
|
|
]
|
|
return results
|