initial_commit

This commit is contained in:
MelvynFaith
2026-11-02 14:58:43 +07:00
commit b06be6e0a7
20 changed files with 547 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Potongan sederhana Marketplace UMKM Lokal (Kelompok D) untuk tugas Week 11."""
+65
View File
@@ -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}
+60
View File
@@ -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)
+83
View File
@@ -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")