Step 3: orders
The model
# ---- orders -----------------------------------------------------------------
PurchaseOrder:
# 주문은 로그인한 고객이 자기 것으로, 조회·취소는 본인, 배송·삭제는 관리자
meta: entity @root @expose(20) @event(Placed(total), Shipped, Cancelled) @owner(customerId)
@access(read=owner, create=user, cancel=owner) @terms(order, purchase, 구매) -- 주문
fields:
orderId: string(36) @id @genid
customer: Customer @ManyToOne # -> customerId
orderDate: date @behavior
status: OrderStatus
deliveryAddress: Address @Embedded
creditCard: CreditCard! @Embedded
totalWithoutVat: money(12.2) @default(0) @compute(sum(lines.subTotal))
vatRate: decimal(5.2) @default(0) @behavior
vat: money(12.2) @default(0) @behavior
discountRate: decimal(5.2) @default(0) @behavior
discount: money(12.2) @default(0) @behavior
total: money(12.2) @default(0) @behavior
lines: List<OrderLine> @OneToMany(mappedBy="order") @dtype(List<OrderLineDTO>) @minsize(1, 'the cart is empty')
version: long @jpa:Version
states: # 주문의 생명주기: 명령마다 어디서 어디로
status:
initial: PLACED
ship: PLACED -> SHIPPED
cancel: PLACED -> CANCELLED
delete: CANCELLED # 지울 수 있는 상태: 취소된 주문만
OrderLine:
meta: entity @behavior -- 주문 줄
fields:
lineId: string(36) @id @genid
order: PurchaseOrder @ManyToOne
item: Item @ManyToOne # -> itemId
quantity: quantity! @min(1)
unitCost: money(10.2) @snapshot(item.unitCost) # 주문 당시 가격을 복사해 둠
subTotal: money(12.2) @compute(unitCost * quantity)
OrderStatus:
meta: enum @db(code) @json(code) -- 주문 상태
fields:
code: string(3) @code
label: string(20)
enum:
PLACED: PLC, Placed
SHIPPED: SHP, Shipped
CANCELLED: CAN, Cancelled
dto:
PurchaseOrderDTO:
meta: dto @template(PurchaseOrder)
OrderLineDTO:
meta: dto @template(OrderLine)
fields:
itemName: string @ref(item.name) # 응답에 품목 이름
(The Korean comments: an order is the signed-in customer's, reading and cancelling it are theirs, shipping and deleting the administrator's; the order's lifecycle, per command from where to where; deletable states - cancelled orders only; the price when ordered is copied; the item's name in the answer.)
What each part does
| Part | Effect |
|---|---|
@owner(customerId) @access(read=owner, create=user, cancel=owner) | a signed-in customer orders (and becomes the owner whatever the request says); reads, searches and cancels only their own; ship and delete are the administrator's |
states: | POST /{orderId}/ship and /cancel; a step from the wrong state answers 409; delete only when CANCELLED |
status: OrderStatus | read-only in the DTO - a request cannot set it |
@snapshot(item.unitCost) | the line keeps the price when it was ordered; a later price change does not touch it |
@compute(...) | subtotals and totalWithoutVat worked out on every save; values sent are ignored |
@behavior fields | only the root's methods set them - the shop's VAT and discount (below) |
@minsize(1, 'the cart is empty') | an order without lines - [] or missing - answers 400 |
@event(Placed(total), Shipped, Cancelled) | ship and cancel raise Shipped and Cancelled; Placed is raised by the code below |
version: long @jpa:Version | two saves of the same order at once: the later one answers 409 |
PurchaseOrder has no @editable field | no PUT: an order changes by its commands only |
The shop's own policy, by hand
VAT and a discount are the business's policy, from settings - the one part of the order the DSL does
not say. The root PurchaseOrder is split: PurchaseOrderBase is regenerated, PurchaseOrder is
yours.
@Entity
@Table(name = "ps_purchase_order")
@DynamicUpdate
public class PurchaseOrder extends PurchaseOrderBase {
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
public void place(LocalDate today, Function<Long, Address> homeOf, BigDecimal vatRate, BigDecimal discountRate) {
if (getOrderDate() != null) throw new InvariantViolationException("order " + getOrderId() + " is already placed");
if (getDeliveryAddress() == null) setDeliveryAddress(homeOf.apply(getCustomerId()));
if (getDeliveryAddress() == null) throw new InvariantViolationException("where should the order go? no delivery address");
Money withoutVat = getTotalWithoutVat();
Money vat = percent(withoutVat, vatRate);
Money discount = percent(withoutVat.plus(vat), discountRate);
setOrderDate(today);
setVatRate(vatRate);
setVat(vat);
setDiscountRate(discountRate);
setDiscount(discount);
setTotal(withoutVat.plus(vat).minus(discount).rounded(2));
raisePlaced();
}
private static Money percent(Money amount, BigDecimal rate) {
return amount.times(rate.divide(HUNDRED, 6, RoundingMode.HALF_UP)).rounded(2);
}
}
@Override
protected void beforeSave(PurchaseOrder order) {
if (order.getOrderDate() == null) {
order.place(LocalDate.now(clock), this::homeOf, vatRate, discountRate);
}
}
The rates come from application.yml:
petstore:
vat-rate: 5.5 # percent, added to the lines
discount-rate: 2.25 # percent, taken off the VAT-inclusive amount
What it answers
Real requests to the sample application, and its answers:
order (not signed in) → 401 "Sign in to create PurchaseOrder"
order (sends unit cost 0.01, status SHP) → 200 unit cost 10.00, status PLC - the server decides
ship (as a customer) → 403 "ship PurchaseOrder is for the administrator"
cancel (a shipped order) → 409 "PurchaseOrder cannot cancel when status is SHIPPED; it must be [PLACED]"
cancel (shipped, Accept-Language: ko) → 409 the same in Korean, code: state.PurchaseOrder.status.cancel
delete (a shipped order) → 409 "PurchaseOrder cannot be deleted when status is SHIPPED; it must be [CANCELLED]"
someone else's order → 404
A body for placing an order (signed in):
{ "lines": [ { "itemId": 1000, "quantity": 2 } ],
"creditCard": { "creditCardNumber": "4111111111111111", "creditCardType": "VISA", "creditCardExpDate": "12/30" } }
The answer carries each line's unitCost and subTotal, the totals, the delivery address (the home
address when none was sent) and no card number. Validation comes before the access check: the same
request without the card answers 400 creditCard: must not be null even for a visitor.