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")
|
||||