از تمرینهای پراکنده تا یک برنامهی واقعی
در این دو درس همهی فصلها را کنار هم میگذاریم و برنامهی خط فرمانی برای یک کارگاه فرش در کاشان میسازیم: ثبت سفارش، تغییر وضعیت (در صف، روی دار، آماده، تحویلشده)، ذخیره در 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کتابخانهی استاندارد یا جنگو است.