initial_commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""Potongan sederhana Marketplace UMKM Lokal (Kelompok D) untuk tugas Week 11."""
|
||||
@@ -0,0 +1,65 @@
|
||||
"""Model produk Catalog. Atribut khusus per kategori mengikuti AR-01 / FR-CAT-03."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from datetime import date
|
||||
|
||||
CLOTHING_SIZES = ("S", "M", "L", "XL")
|
||||
|
||||
|
||||
class Product(ABC):
|
||||
"""Kelas dasar produk. Subclass menentukan atribut khusus kategorinya."""
|
||||
|
||||
category = "generic"
|
||||
|
||||
def __init__(self, product_id, shop_id, name, price, on_hand, reserved=0):
|
||||
if price < 0:
|
||||
raise ValueError("price tidak boleh negatif")
|
||||
if not 0 <= reserved <= on_hand:
|
||||
raise ValueError("reserved harus di antara 0 dan on_hand")
|
||||
self.product_id = product_id
|
||||
self.shop_id = shop_id
|
||||
self.name = name
|
||||
self.price = price
|
||||
self.on_hand = on_hand
|
||||
self.reserved = reserved
|
||||
|
||||
@property
|
||||
def available(self):
|
||||
"""Stok yang dapat dipesan: onHand - reserved (glosarium SRS)."""
|
||||
return self.on_hand - self.reserved
|
||||
|
||||
def is_orderable(self, today):
|
||||
"""Aturan dasar: produk dapat dipesan jika masih ada stok tersedia."""
|
||||
return self.available > 0
|
||||
|
||||
@abstractmethod
|
||||
def attributes(self):
|
||||
"""Atribut khusus kategori, dikembalikan sebagai dict."""
|
||||
|
||||
|
||||
class FoodProduct(Product):
|
||||
category = "food"
|
||||
|
||||
def __init__(self, product_id, shop_id, name, price, on_hand, expiry_date, reserved=0):
|
||||
super().__init__(product_id, shop_id, name, price, on_hand, reserved)
|
||||
self.expiry_date = expiry_date
|
||||
|
||||
def is_orderable(self, today):
|
||||
"""Makanan tidak boleh dipesan setelah tanggal kedaluwarsa."""
|
||||
return super().is_orderable(today) and today <= self.expiry_date
|
||||
|
||||
def attributes(self):
|
||||
return {"expiryDate": self.expiry_date.isoformat()}
|
||||
|
||||
|
||||
class ClothingProduct(Product):
|
||||
category = "clothing"
|
||||
|
||||
def __init__(self, product_id, shop_id, name, price, on_hand, size, reserved=0):
|
||||
if size not in CLOTHING_SIZES:
|
||||
raise ValueError(f"size harus salah satu dari {CLOTHING_SIZES}")
|
||||
super().__init__(product_id, shop_id, name, price, on_hand, reserved)
|
||||
self.size = size
|
||||
|
||||
def attributes(self):
|
||||
return {"size": self.size}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Modul prosedural validasi checkout (FR-ORD-02, FR-ORD-03, FR-ORD-08).
|
||||
|
||||
Harga diambil dari katalog, bukan dari input klien. Satu order hanya boleh
|
||||
berisi produk dari satu toko. Keranjang kosong mengembalikan total 0; penolakan
|
||||
keranjang kosong menjadi tanggung jawab pemanggil.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class CheckoutError(Exception):
|
||||
"""Kelas dasar semua kegagalan validasi checkout."""
|
||||
|
||||
|
||||
class ProductNotFoundError(CheckoutError):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidQuantityError(CheckoutError):
|
||||
pass
|
||||
|
||||
|
||||
class MultiShopError(CheckoutError):
|
||||
pass
|
||||
|
||||
|
||||
class InsufficientStockError(CheckoutError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CartItem:
|
||||
product_id: str
|
||||
quantity: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckoutSummary:
|
||||
shop_id: str | None
|
||||
total_amount: int
|
||||
|
||||
|
||||
def validate_checkout(items, products):
|
||||
"""Validasi keranjang terhadap katalog lalu hitung total dalam rupiah."""
|
||||
shop_id = None
|
||||
total = 0
|
||||
for item in items:
|
||||
product = products.get(item.product_id)
|
||||
if product is None:
|
||||
raise ProductNotFoundError(item.product_id)
|
||||
if item.quantity <= 0:
|
||||
raise InvalidQuantityError(item.product_id)
|
||||
if shop_id is None:
|
||||
shop_id = product.shop_id
|
||||
elif product.shop_id != shop_id:
|
||||
raise MultiShopError(item.product_id)
|
||||
if product.available < item.quantity:
|
||||
raise InsufficientStockError(item.product_id)
|
||||
total += product.price * item.quantity
|
||||
return CheckoutSummary(shop_id, total)
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Mesin status order (FR-ORD-04, FR-ORD-09, FR-ORD-10, UC-07, UC-08)."""
|
||||
|
||||
from datetime import timedelta
|
||||
from enum import Enum
|
||||
|
||||
AUTO_COMPLETE_AFTER = timedelta(days=3)
|
||||
|
||||
|
||||
class OrderStatus(Enum):
|
||||
PENDING_PAYMENT = "PENDING_PAYMENT"
|
||||
PAID = "PAID"
|
||||
SHIPPED = "SHIPPED"
|
||||
COMPLETED = "COMPLETED"
|
||||
CANCELLED = "CANCELLED"
|
||||
EXPIRED = "EXPIRED"
|
||||
|
||||
|
||||
class InvalidTransitionError(Exception):
|
||||
"""Event tidak diizinkan pada status order saat ini."""
|
||||
|
||||
|
||||
class PermissionDeniedError(Exception):
|
||||
"""Pelaku tidak berhak memicu event ini."""
|
||||
|
||||
|
||||
S = OrderStatus
|
||||
|
||||
# Tabel transisi: (status asal, event) -> status tujuan. Pasangan lain ditolak.
|
||||
TRANSITIONS = {
|
||||
(S.PENDING_PAYMENT, "pay"): S.PAID,
|
||||
(S.PENDING_PAYMENT, "cancel"): S.CANCELLED,
|
||||
(S.PENDING_PAYMENT, "expire"): S.EXPIRED,
|
||||
(S.PAID, "ship"): S.SHIPPED,
|
||||
(S.SHIPPED, "complete"): S.COMPLETED,
|
||||
}
|
||||
|
||||
|
||||
class Order:
|
||||
def __init__(self, order_id, buyer_id, seller_id):
|
||||
self.order_id = order_id
|
||||
self.buyer_id = buyer_id
|
||||
self.seller_id = seller_id
|
||||
self.status = S.PENDING_PAYMENT
|
||||
self.shipped_at = None
|
||||
|
||||
def _fire(self, event):
|
||||
target = TRANSITIONS.get((self.status, event))
|
||||
if target is None:
|
||||
raise InvalidTransitionError(f"{event} tidak valid pada status {self.status.value}")
|
||||
self.status = target
|
||||
|
||||
def pay(self):
|
||||
"""Event payment.succeeded dari Payment Service."""
|
||||
self._fire("pay")
|
||||
|
||||
def cancel(self, actor_id):
|
||||
"""Pembeli membatalkan order yang belum dibayar."""
|
||||
if actor_id != self.buyer_id:
|
||||
raise PermissionDeniedError("hanya pembeli yang dapat membatalkan")
|
||||
self._fire("cancel")
|
||||
|
||||
def expire(self):
|
||||
"""Batas waktu pembayaran habis (dipicu sistem)."""
|
||||
self._fire("expire")
|
||||
|
||||
def ship(self, actor_id, now):
|
||||
"""Seller pemilik toko menandai order terkirim."""
|
||||
if actor_id != self.seller_id:
|
||||
raise PermissionDeniedError("hanya seller pemilik toko yang dapat mengirim")
|
||||
self._fire("ship")
|
||||
self.shipped_at = now
|
||||
|
||||
def confirm_received(self, actor_id):
|
||||
"""Pembeli mengonfirmasi pesanan diterima. Seller tidak boleh."""
|
||||
if actor_id != self.buyer_id:
|
||||
raise PermissionDeniedError("hanya pembeli yang dapat mengonfirmasi")
|
||||
self._fire("complete")
|
||||
|
||||
def auto_complete(self, now):
|
||||
"""Sistem menyelesaikan order 3 hari setelah SHIPPED tanpa konfirmasi."""
|
||||
if self.status is S.SHIPPED and now - self.shipped_at < AUTO_COMPLETE_AFTER:
|
||||
raise InvalidTransitionError("belum 3 hari sejak SHIPPED")
|
||||
self._fire("complete")
|
||||
Reference in New Issue
Block a user