فصل ۸: پروژه و حرفه‌ای شدن — پروژه‌ی پایانی، API، تست، دیباگ و کلینیک خطا

پروژه‌ی پایانی (۱): طراحی «مدیریت سفارش فرش»، مدل داده و ذخیره‌ی JSON

از تمرین‌های پراکنده تا یک برنامه‌ی واقعی

در این دو درس همه‌ی فصل‌ها را کنار هم می‌گذاریم و برنامه‌ی خط فرمانی برای یک کارگاه فرش در کاشان می‌سازیم: ثبت سفارش، تغییر وضعیت (در صف، روی دار، آماده، تحویل‌شده)، ذخیره در JSON و گزارش ماهانه‌ی شمسی. مهم‌تر از کد، تصمیم‌های طراحی است.

ساختار پروژه

carpet-orders/
├── .venv/
├── requirements.txt      # jdatetime، pytest، requests
├── store.py              # مدل داده و ذخیره‌سازی — بدون print و input
├── cli.py                # رابط خط فرمان — فقط ورودی و خروجی
└── tests/
    └── test_store.py

قاعده‌ی طلایی: منطق را از ورودی و خروجی جدا کنید. store.py هیچ print یا input ندارد؛ پس تست‌پذیر است و روزی بدون تغییر زیر یک سایت جنگو هم کار می‌کند.

store.py: مدل و ذخیره‌سازی

# store.py — مدل داده و ذخیره‌سازی سفارش‌های فرش (بدون print و input)
import json
import os
from dataclasses import asdict, dataclass, field
from datetime import UTC, datetime
from pathlib import Path

DATA_FILE = Path(__file__).with_name("orders.json")
STATUSES = ("queued", "weaving", "ready", "delivered")


def now_utc() -> str:
    return datetime.now(UTC).isoformat(timespec="seconds")


@dataclass
class Order:
    code: str
    customer: str
    design: str          # افشان، ماهی، لچک‌ترنج ...
    size: str            # مثل "3x4"
    price: int           # تومان — پول را float نمی‌گذاریم
    status: str = "queued"
    created: str = field(default_factory=now_utc)

    def __post_init__(self):
        if self.price <= 0:
            raise ValueError(f"قیمت نامعتبر: {self.price}")
        if self.status not in STATUSES:
            raise ValueError(f"وضعیت نامعتبر: {self.status}")


def load_orders(path: Path = DATA_FILE) -> list[Order]:
    if not path.exists():
        return []
    text = path.read_text(encoding="utf-8").strip()
    if not text:                       # فایل خالی، نه JSON خراب
        return []
    return [Order(**row) for row in json.loads(text)]


def save_orders(orders: list[Order], path: Path = DATA_FILE) -> None:
    tmp = path.with_suffix(".tmp")
    data = [asdict(o) for o in orders]
    tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
    os.replace(tmp, path)              # جایگزینی اتمیک


def next_code(orders: list[Order], prefix: str = "KSH") -> str:
    numbers = [int(o.code.split("-")[1]) for o in orders]
    return f"{prefix}-{max(numbers, default=100) + 1}"


def toman(text: str) -> int:
    # '۵۷٬۰۰۰٬۰۰۰' یا '57,000,000' را به 57000000 تبدیل می‌کند
    table = str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789", ",٬ ")
    return int(text.translate(table))

چرا این‌طور نوشتیم؟

  • کد سفارش رشته است: «KSH-107» پیشوند شعبه دارد و max(..., default=100) حالت فایل خالی را بدون if پوشش می‌دهد.
  • قیمت int و به تومان است (فصل ۲). toman ارقام فارسی و عربی و جداکننده‌ی هزارگان را می‌پذیرد.
  • زمان ثبت به‌صورت ISO و UTC ذخیره می‌شود و فقط هنگام نمایش شمسی می‌شود؛ قاعده‌ی فصل ۷.
  • ذخیره‌ی اتمیک: اول در فایل موقت می‌نویسیم و بعد با os.replace جایگزین می‌کنیم. اگر وسط نوشتن برق برود، فایل اصلی سالم می‌ماند، نه نیمه‌نوشته.

امتحان سریع

from pathlib import Path
from store import Order, load_orders, save_orders, next_code

path = Path("demo.json")
orders = load_orders(path)                       # فایل وجود ندارد: []
orders.append(Order(next_code(orders), "رضا نراقی", "افشان", "3x4", 57_000_000))
orders.append(Order(next_code(orders), "زهرا قمصری", "ماهی", "2x3", 31_500_000))
save_orders(orders, path)
print([o.code for o in load_orders(path)])       # ['KSH-101', 'KSH-102']

نکته‌هایی که کمتر کسی می‌داند

  • در ویندوز os.rename اگر فایل مقصد وجود داشته باشد FileExistsError می‌دهد، ولی os.replace روی هر دو سیستم‌عامل جایگزین می‌کند و اتمیک است؛ برای الگوی «فایل موقت، بعد جایگزینی» همیشه replace.
  • Path(__file__).with_name(...) فایل داده را کنار اسکریپت نگه می‌دارد. اگر فقط Path("orders.json") بنویسید و برنامه را از پوشه‌ی دیگری اجرا کنید، فایل خالی تازه‌ای ساخته می‌شود و خیال می‌کنید سفارش‌ها پاک شده‌اند.
  • Order(**row) با کلید اضافه در JSON خطای TypeError (unexpected keyword argument) می‌دهد. وقتی فیلدی را حذف می‌کنید، فایل‌های قدیمی را با {k: v for k, v in row.items() if k in Order.__dataclass_fields__} بخوانید.
  • json.loads("") لیست خالی نمی‌دهد، JSONDecodeError می‌دهد؛ برای همین فایل صفربایتی (مثلاً خالی‌شده با Notepad) را جدا بررسی کردیم.
  • JSON برای چند هزار سفارش و یک کاربر کافی است؛ وقتی دو نفر هم‌زمان می‌نویسند، آخرین ذخیره کار دیگری را پاک می‌کند. آن روز وقت مهاجرت به sqlite3 کتابخانه‌ی استاندارد یا جنگو است.

برای ذخیره‌ی پیشرفت و شرکت در آزمون، وارد شوید — رایگان است.