DEV Community

Ujjwal Dubey
Ujjwal Dubey

Posted on

Modelling a Custom Jewellery Order as a State Machine (with Human Approval Gates)

Custom jewellery orders are a nice, small example of why "add a chatbot" is the wrong first move for retail automation. The real problem is state: the customer wants to know where their order is, and the answer lives in someone's head or a paper order book.

This post shows a tiny state machine for a custom order, with approval gates on the steps that touch money or promises. It is the pattern we use when we build AI automation for custom jewellery orders for Indian stores, stripped down to the standard library.

The states

ENQUIRY -> DESIGN_SHARED -> DESIGN_APPROVED -> ADVANCE_RECEIVED -> IN_MAKING
        -> READY_FOR_TRIAL -> ALTERATION (optional loop) -> READY_FOR_PICKUP -> DELIVERED
Enter fullscreen mode Exit fullscreen mode

Plus two side exits: DELAYED (needs a human message) and CANCELLED.

The rules

  • Every transition is made by a named staff member.
  • Some transitions trigger a customer message automatically from an approved template.
  • Some transitions only draft a message, which a person must approve: anything about price, balance due or delay.

The code

from dataclasses import dataclass, field
from datetime import datetime

TRANSITIONS = {
    "ENQUIRY": {"DESIGN_SHARED", "CANCELLED"},
    "DESIGN_SHARED": {"DESIGN_APPROVED", "CANCELLED"},
    "DESIGN_APPROVED": {"ADVANCE_RECEIVED", "CANCELLED"},
    "ADVANCE_RECEIVED": {"IN_MAKING"},
    "IN_MAKING": {"READY_FOR_TRIAL", "DELAYED"},
    "DELAYED": {"IN_MAKING", "READY_FOR_TRIAL"},
    "READY_FOR_TRIAL": {"ALTERATION", "READY_FOR_PICKUP"},
    "ALTERATION": {"READY_FOR_TRIAL"},
    "READY_FOR_PICKUP": {"DELIVERED"},
}

AUTO_SEND = {"DESIGN_APPROVED", "IN_MAKING", "READY_FOR_TRIAL", "READY_FOR_PICKUP"}
NEEDS_APPROVAL = {"ADVANCE_RECEIVED", "DELAYED", "DELIVERED"}  # money, promises, final balance

@dataclass
class Order:
    order_id: str
    customer_phone: str
    language: str
    state: str = "ENQUIRY"
    history: list = field(default_factory=list)

def move(order, new_state, staff, outbox, approvals):
    if new_state not in TRANSITIONS.get(order.state, set()):
        raise ValueError(f"{order.state} -> {new_state} not allowed")
    order.history.append((datetime.now().isoformat(), order.state, new_state, staff))
    order.state = new_state
    msg = {"to": order.customer_phone, "template": f"{new_state.lower()}_{order.language}",
           "ref": f"{order.order_id}:{new_state}:{len(order.history)}"}
    if new_state in AUTO_SEND:
        outbox.append(msg)
    elif new_state in NEEDS_APPROVAL:
        approvals.append({**msg, "requested_by": staff})

outbox, approvals = [], []
o = Order("JO-0001", "+910000000000", "hi")
for s, who in [("DESIGN_SHARED", "asha"), ("DESIGN_APPROVED", "asha"),
               ("ADVANCE_RECEIVED", "owner"), ("IN_MAKING", "ravi"), ("DELAYED", "ravi")]:
    move(o, s, who, outbox, approvals)
print("auto:", [m["template"] for m in outbox])
print("waiting for approval:", [m["template"] for m in approvals])
Enter fullscreen mode Exit fullscreen mode

Output:

auto: ['design_approved_hi', 'in_making_hi']
waiting for approval: ['advance_received_hi', 'delayed_hi']
Enter fullscreen mode Exit fullscreen mode

Why the ref field matters

WhatsApp providers and webhooks retry. The ref (order, state, step) lets your sender drop duplicates, so a retry never sends "your order is ready" twice.

What I left out

Persistence, the provider API call, language detection and the approval UI. In production the approvals list is a one-tap screen on the owner's phone. The interesting part is the design decision, not the code: which transitions may talk to a customer on their own, and which must wait for a person.

If you are mapping a workflow like this for a client, the hard work is before the code. Here is the pre-build audit checklist we use; our own first audit takes 72 hours.

Top comments (0)