initial_commit
@@ -0,0 +1,18 @@
|
|||||||
|
# Python
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
|
||||||
|
# Test dan coverage (dibuat ulang oleh perintah pytest)
|
||||||
|
.pytest_cache/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
htmlcov/
|
||||||
|
|
||||||
|
# Editor dan sistem operasi
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
~$*.docx
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Week 11: Pengujian White-Box Marketplace UMKM Lokal
|
||||||
|
|
||||||
|
Tugas Software Testing Week 11, Kelompok D. Berisi potongan sederhana dari logika Order dan Catalog pada proyek [Marketplace UMKM Lokal](https://github.com/MelvynFaith/umkm-marketplace), beserta test PyTest dan analisis coverage.
|
||||||
|
|
||||||
|
Isi tugas:
|
||||||
|
|
||||||
|
1. Control flow graph, V(G), dan jalur independen untuk `validate_checkout`
|
||||||
|
2. Test PyTest yang menutup semua jalur
|
||||||
|
3. State machine dan tabel transisi untuk class `Order`
|
||||||
|
4. Test class: transisi valid, transisi tidak valid, dan polimorfisme
|
||||||
|
5. Laporan `--cov-branch` beserta analisisnya
|
||||||
|
|
||||||
|
Penjelasan lengkap ada di [`docs/laporan.md`](docs/laporan.md) (versi Word: `docs/laporan.docx`).
|
||||||
|
|
||||||
|
## Struktur
|
||||||
|
|
||||||
|
```text
|
||||||
|
umkm/
|
||||||
|
checkout.py validate_checkout (modul prosedural, V(G) = 7)
|
||||||
|
order.py class Order (state machine, 6 state)
|
||||||
|
catalog.py Product, FoodProduct, ClothingProduct (pewarisan dan polimorfisme)
|
||||||
|
tests/
|
||||||
|
test_checkout_paths.py satu test per jalur independen (P1-P7)
|
||||||
|
test_order_state.py seluruh matriks transisi 6 x 6 dan guard pelaku
|
||||||
|
test_product_polymorphism.py pewarisan dan polimorfisme Product
|
||||||
|
docs/
|
||||||
|
laporan.md, laporan.docx laporan
|
||||||
|
*.png diagram dan screenshot bukti
|
||||||
|
```
|
||||||
|
|
||||||
|
## Menjalankan
|
||||||
|
|
||||||
|
Membutuhkan Python 3.10 atau lebih baru (diuji pada Python 3.14).
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
python -m pytest
|
||||||
|
```
|
||||||
|
|
||||||
|
Dengan laporan branch coverage:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pytest --cov=umkm --cov-branch --cov-report=term-missing --cov-report=html:htmlcov
|
||||||
|
```
|
||||||
|
|
||||||
|
Laporan HTML dibuat di `htmlcov/index.html`.
|
||||||
|
|
||||||
|
## Hasil
|
||||||
|
|
||||||
|
67 test lulus. Statement coverage dan branch coverage 100% (28 dari 28 branch). Analisisnya ada di bagian 5 laporan.
|
||||||
|
After Width: | Height: | Size: 153 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 168 KiB |
|
After Width: | Height: | Size: 156 KiB |
|
After Width: | Height: | Size: 80 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
[pytest]
|
||||||
|
pythonpath = .
|
||||||
|
testpaths = tests
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
pytest==9.1.1
|
||||||
|
pytest-cov==7.1.0
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
"""Pengujian jalur independen validate_checkout (V(G) = 7).
|
||||||
|
|
||||||
|
Satu test per jalur basis. Nomor P1..P7 sama dengan tabel di docs/laporan.md.
|
||||||
|
Kebutuhan: FR-ORD-02, FR-ORD-03, FR-ORD-08.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from umkm.catalog import ClothingProduct
|
||||||
|
from umkm.checkout import (
|
||||||
|
CartItem,
|
||||||
|
CheckoutSummary,
|
||||||
|
InsufficientStockError,
|
||||||
|
InvalidQuantityError,
|
||||||
|
MultiShopError,
|
||||||
|
ProductNotFoundError,
|
||||||
|
validate_checkout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def products():
|
||||||
|
return {
|
||||||
|
"p1": ClothingProduct("p1", "shop-A", "Kaos", price=50_000, on_hand=10, size="M"),
|
||||||
|
"p2": ClothingProduct("p2", "shop-A", "Kemeja", price=120_000, on_hand=5, size="L"),
|
||||||
|
"p3": ClothingProduct("p3", "shop-B", "Jaket", price=200_000, on_hand=5, size="XL"),
|
||||||
|
"p4": ClothingProduct("p4", "shop-A", "Topi", price=30_000, on_hand=3, size="S", reserved=3),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_p1_keranjang_kosong_total_nol(products):
|
||||||
|
"""P1: loop tidak dimasuki."""
|
||||||
|
assert validate_checkout([], products) == CheckoutSummary(None, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def test_p2_produk_tidak_ada(products):
|
||||||
|
"""P2: product is None -> ProductNotFoundError."""
|
||||||
|
with pytest.raises(ProductNotFoundError):
|
||||||
|
validate_checkout([CartItem("tidak-ada", 1)], products)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("qty", [0, -1])
|
||||||
|
def test_p3_kuantitas_tidak_valid(products, qty):
|
||||||
|
"""P3: quantity <= 0 -> InvalidQuantityError (batas 0 dan negatif)."""
|
||||||
|
with pytest.raises(InvalidQuantityError):
|
||||||
|
validate_checkout([CartItem("p1", qty)], products)
|
||||||
|
|
||||||
|
|
||||||
|
def test_p4_stok_tidak_cukup_pada_item_pertama(products):
|
||||||
|
"""P4: available < quantity pada iterasi pertama."""
|
||||||
|
with pytest.raises(InsufficientStockError):
|
||||||
|
validate_checkout([CartItem("p4", 1)], products)
|
||||||
|
|
||||||
|
|
||||||
|
def test_p5_satu_item_valid(products):
|
||||||
|
"""P5: jalur baseline, satu item lolos semua cek."""
|
||||||
|
assert validate_checkout([CartItem("p1", 2)], products) == CheckoutSummary("shop-A", 100_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_p6_dua_item_satu_toko(products):
|
||||||
|
"""P6: iterasi kedua, toko sama -> shop_id tidak berubah, total dijumlahkan."""
|
||||||
|
result = validate_checkout([CartItem("p1", 2), CartItem("p2", 1)], products)
|
||||||
|
assert result == CheckoutSummary("shop-A", 2 * 50_000 + 120_000)
|
||||||
|
|
||||||
|
|
||||||
|
def test_p7_dua_item_beda_toko(products):
|
||||||
|
"""P7: iterasi kedua, toko berbeda -> MultiShopError (FR-ORD-08)."""
|
||||||
|
with pytest.raises(MultiShopError):
|
||||||
|
validate_checkout([CartItem("p1", 1), CartItem("p3", 1)], products)
|
||||||
|
|
||||||
|
|
||||||
|
def test_batas_stok_tepat_sama_lolos(products):
|
||||||
|
"""Batas available == quantity harus lolos (cabang stok false)."""
|
||||||
|
assert validate_checkout([CartItem("p2", 5)], products).total_amount == 600_000
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"""Pengujian mesin status Order: transisi valid, tidak valid, dan guard pelaku.
|
||||||
|
|
||||||
|
Kebutuhan: FR-ORD-04, FR-ORD-06, FR-ORD-09, FR-ORD-10.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from umkm.order import (
|
||||||
|
TRANSITIONS,
|
||||||
|
InvalidTransitionError,
|
||||||
|
Order,
|
||||||
|
OrderStatus as S,
|
||||||
|
PermissionDeniedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
BUYER, SELLER = "buyer-1", "seller-1"
|
||||||
|
T0 = datetime(2026, 11, 1, 10, 0)
|
||||||
|
|
||||||
|
|
||||||
|
def new_order():
|
||||||
|
return Order("o-1", BUYER, SELLER)
|
||||||
|
|
||||||
|
|
||||||
|
def order_in(status):
|
||||||
|
"""Bawa order ke status yang diminta lewat transisi sungguhan."""
|
||||||
|
order = new_order()
|
||||||
|
if status in (S.PAID, S.SHIPPED, S.COMPLETED):
|
||||||
|
order.pay()
|
||||||
|
if status in (S.SHIPPED, S.COMPLETED):
|
||||||
|
order.ship(SELLER, T0)
|
||||||
|
if status is S.COMPLETED:
|
||||||
|
order.confirm_received(BUYER)
|
||||||
|
if status is S.CANCELLED:
|
||||||
|
order.cancel(BUYER)
|
||||||
|
if status is S.EXPIRED:
|
||||||
|
order.expire()
|
||||||
|
assert order.status is status
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
# Semua event yang dapat dipicu; auto_complete memakai waktu tepat jatuh tempo.
|
||||||
|
EVENTS = {
|
||||||
|
"pay": lambda o: o.pay(),
|
||||||
|
"cancel": lambda o: o.cancel(BUYER),
|
||||||
|
"expire": lambda o: o.expire(),
|
||||||
|
"ship": lambda o: o.ship(SELLER, T0),
|
||||||
|
"complete": lambda o: o.confirm_received(BUYER),
|
||||||
|
"auto_complete": lambda o: o.auto_complete(T0 + timedelta(days=3)),
|
||||||
|
}
|
||||||
|
VALID = dict(TRANSITIONS)
|
||||||
|
VALID[(S.SHIPPED, "auto_complete")] = S.COMPLETED
|
||||||
|
INVALID = [(s, e) for s in S for e in EVENTS if (s, e) not in VALID]
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_baru_berstatus_pending_payment():
|
||||||
|
assert new_order().status is S.PENDING_PAYMENT
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("src", "event"), list(VALID))
|
||||||
|
def test_transisi_valid(src, event):
|
||||||
|
order = order_in(src)
|
||||||
|
EVENTS[event](order)
|
||||||
|
assert order.status is VALID[(src, event)]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("src", "event"), INVALID)
|
||||||
|
def test_transisi_tidak_valid_ditolak_dan_status_tetap(src, event):
|
||||||
|
order = order_in(src)
|
||||||
|
with pytest.raises(InvalidTransitionError):
|
||||||
|
EVENTS[event](order)
|
||||||
|
assert order.status is src
|
||||||
|
|
||||||
|
|
||||||
|
def test_jumlah_kombinasi_lengkap():
|
||||||
|
"""6 status x 6 event = 36 kombinasi: 6 valid + 30 tidak valid."""
|
||||||
|
assert len(VALID) == 6
|
||||||
|
assert len(INVALID) == 30
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("final", [S.COMPLETED, S.CANCELLED, S.EXPIRED])
|
||||||
|
def test_status_akhir_tidak_punya_transisi_keluar(final):
|
||||||
|
assert not [key for key in VALID if key[0] is final]
|
||||||
|
|
||||||
|
|
||||||
|
def test_seller_tidak_boleh_menyelesaikan_order():
|
||||||
|
"""FR-ORD-10: hanya pembeli yang mengonfirmasi."""
|
||||||
|
order = order_in(S.SHIPPED)
|
||||||
|
with pytest.raises(PermissionDeniedError):
|
||||||
|
order.confirm_received(SELLER)
|
||||||
|
assert order.status is S.SHIPPED
|
||||||
|
|
||||||
|
|
||||||
|
def test_hanya_seller_pemilik_toko_boleh_mengirim():
|
||||||
|
order = order_in(S.PAID)
|
||||||
|
with pytest.raises(PermissionDeniedError):
|
||||||
|
order.ship("seller-lain", T0)
|
||||||
|
assert order.status is S.PAID
|
||||||
|
|
||||||
|
|
||||||
|
def test_hanya_pembeli_pemilik_order_boleh_membatalkan():
|
||||||
|
order = new_order()
|
||||||
|
with pytest.raises(PermissionDeniedError):
|
||||||
|
order.cancel("buyer-lain")
|
||||||
|
assert order.status is S.PENDING_PAYMENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_complete_ditolak_sebelum_3_hari():
|
||||||
|
order = order_in(S.SHIPPED)
|
||||||
|
with pytest.raises(InvalidTransitionError):
|
||||||
|
order.auto_complete(T0 + timedelta(days=3) - timedelta(seconds=1))
|
||||||
|
assert order.status is S.SHIPPED
|
||||||
|
|
||||||
|
|
||||||
|
def test_auto_complete_tepat_3_hari_berhasil():
|
||||||
|
order = order_in(S.SHIPPED)
|
||||||
|
order.auto_complete(T0 + timedelta(days=3))
|
||||||
|
assert order.status is S.COMPLETED
|
||||||
|
|
||||||
|
|
||||||
|
def test_ship_mencatat_waktu_pengiriman():
|
||||||
|
assert order_in(S.SHIPPED).shipped_at == T0
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"""Pewarisan dan polimorfisme hierarki Product (AR-01, FR-CAT-03)."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from umkm.catalog import ClothingProduct, FoodProduct, Product
|
||||||
|
|
||||||
|
TODAY = date(2026, 11, 2)
|
||||||
|
|
||||||
|
|
||||||
|
def make_products():
|
||||||
|
return [
|
||||||
|
FoodProduct("f1", "shop-A", "Keripik", 15_000, 10, expiry_date=date(2026, 12, 1)),
|
||||||
|
ClothingProduct("c1", "shop-A", "Kaos", 50_000, 10, size="M"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_subclass_adalah_product():
|
||||||
|
assert all(isinstance(p, Product) for p in make_products())
|
||||||
|
|
||||||
|
|
||||||
|
def test_product_abstrak_tidak_bisa_dibuat():
|
||||||
|
with pytest.raises(TypeError):
|
||||||
|
Product("x", "s", "x", 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def test_attributes_polimorfik_lewat_referensi_basis():
|
||||||
|
"""Pemanggilan yang sama pada tipe basis memberi hasil berbeda per subclass."""
|
||||||
|
hasil = {p.category: p.attributes() for p in make_products()}
|
||||||
|
assert hasil == {"food": {"expiryDate": "2026-12-01"}, "clothing": {"size": "M"}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_diwarisi_semua_subclass():
|
||||||
|
for p in make_products():
|
||||||
|
p.reserved = 4
|
||||||
|
assert p.available == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_food_kedaluwarsa_tidak_bisa_dipesan_override():
|
||||||
|
food = FoodProduct("f1", "s", "Keripik", 1, 10, expiry_date=date(2026, 11, 1))
|
||||||
|
assert food.is_orderable(TODAY) is False
|
||||||
|
assert food.is_orderable(date(2026, 11, 1)) is True # hari kedaluwarsa masih boleh
|
||||||
|
|
||||||
|
|
||||||
|
def test_food_stok_habis_tidak_bisa_dipesan_lewat_super():
|
||||||
|
food = FoodProduct("f1", "s", "Keripik", 1, 5, expiry_date=date(2027, 1, 1), reserved=5)
|
||||||
|
assert food.is_orderable(TODAY) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_clothing_memakai_aturan_dasar_tanpa_override():
|
||||||
|
assert "is_orderable" not in ClothingProduct.__dict__
|
||||||
|
assert ClothingProduct("c", "s", "Kaos", 1, 1, size="S").is_orderable(TODAY) is True
|
||||||
|
assert ClothingProduct("c", "s", "Kaos", 1, 1, size="S", reserved=1).is_orderable(TODAY) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_clothing_size_tidak_valid_ditolak():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ClothingProduct("c", "s", "Kaos", 1, 1, size="XXL")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("price", "on_hand", "reserved"),
|
||||||
|
[(-1, 1, 0), (1, 1, 2), (1, 1, -1)],
|
||||||
|
)
|
||||||
|
def test_validasi_konstruktor_basis(price, on_hand, reserved):
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
ClothingProduct("c", "s", "Kaos", price, on_hand, size="M", reserved=reserved)
|
||||||
@@ -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")
|
||||||