온라인 쇼핑몰 시스템이 있는 이유는 단순합니다. 채팅 스레드로 가게를 굴리는 판매자는 결국 주문 하나를 잃어버립니다. 그것을 대신하는 것은 일부러 화려하지 않습니다. 검색과 분류 필터가 있는 카탈로그, 누르기 전에 재고를 보여주는 상품 페이지, 어느 가게 것인지 절대 모호하지 않은 장바구니, 그리고 이름 붙은 다섯 단계를 지나며 전환마다 알림을 보내는 주문.
상품 페이지: 옵션과 재고, 그리고 누르기 전의 할인
상품마다 사진과 설명과 재고가 있고, 가격은 할인될 수 있습니다. 할인가는 취소선이 그어진 원가 옆에 퍼센트 배지와 함께 표시되어, 할인 폭이 암시가 아니라 읽히는 숫자가 됩니다. 품절이면 결제 단계에서 알게 되는 것이 아니라 사진 위에 그렇게 그려집니다. 앱 전체가 따르는 규칙입니다. 누름의 결과를 바꾸는 것은 누르기 전에 보여야 합니다. 버튼은 다음 화면이 거절할 것을 약속해서는 안 됩니다.
옵션 조합은 하나씩 입력하는 것이 아니라 속성에서 생성됩니다. 판매자가 축을 선언하면 — 사이즈, 색상, 그 상품이 실제로 달라지는 지점 — 조합이 각자 재고를 가진 항목이 됩니다. 흔한 실패를 데이터에서 걷어내기 위해서입니다. 같은 셔츠를 따로 등록한 세 개의 목록은 함께 집계할 수 없고, 재고 숫자는 그중 하나에서만 맞습니다.
장바구니는 한 가게의 것입니다
장바구니는 한 가게만 담습니다. 다른 판매자의 상품을 담으려 하면 지금 담긴 것을 비울지 먼저 묻습니다. 네 개 비즈니스에 걸친 바구니를 조용히 쌓아 올리지 않습니다. 빠진 기능이 아니라 설계상의 거절입니다. 여러 가게에 걸친 장바구니는 하나의 결제, 하나의 배송비 계산, 물건이 오지 않았을 때의 하나의 책임 주체를 전제합니다. 네 개의 독립된 동네 가게가 각자의 배송 방식을 갖고 있는 상황에서는 그중 어느 것도 존재하지 않습니다.
장바구니 하나에 가게 하나, 의도적으로 그렇습니다. 가게를 바꾸면 장바구니를 비울지 묻습니다. 여러 판매자에 걸친 바구니가 정직하려면 통합 결제가 있어야 하는데, 여기에는 마켓플레이스 전체 결제라는 것이 없습니다. 각 주문은 구매자 한 명과 비즈니스 하나 사이의 것입니다.
배송비는 앱이 보내는 값이 아닙니다. 주문이 들어올 때 서버가 그 가게의 레코드에서 읽으며, 배송을 고른 경우에만 붙습니다.
결제: 배송이냐 픽업이냐, 그리고 클라이언트가 정할 수 없는 배송비
결제는 주문에 실제로 필요한 것만 묻습니다. 배송 또는 픽업, 이름, 전화번호, 주소, 메모, 결제 수단. 픽업을 고르면 배송비가 0으로 표시되는 것이 아니라 항목 자체가 사라집니다. 두 상태는 다른 뜻이고 0원은 버그처럼 읽히기 때문입니다. 주소는 기본값이 있는 주소록에서 가져오므로 두 번째 주문에서 같은 주소를 다시 입력하지 않습니다. 그리고 주소록은 프로필과 공유하는 하나뿐입니다. 프로필 주소와 배송 주소는 같은 사실이고, 같은 사실을 두 곳에 저장하면 두 사본은 반드시 언젠가 어긋납니다.
배송비 자체는 서버 쪽입니다. place_shop_order는 데이터베이스에서 businesses.delivery_fee를 읽고 클라이언트가 보낸 값을 무시합니다. 매장 주문이 모든 줄의 가격을 직접 매기는 것과 같습니다. 신뢰받는 클라이언트 필드는 셀프 할인권이고, 돈은 서버의 것입니다. 설정에서 끌 수 있는 정책이 아니라 숫자가 나오는 출처 그 자체입니다.
다섯 개의 주문 상태, 송장 번호, 양쪽 모두에게 알림
주문은 대기 → 확인 → 포장 → 발송 → 완료를 지나가고, 취소와 거절이 종료 분기로 붙습니다. 구매자는 상태 단어 하나가 아니라 진행 막대를 봅니다. 그래서 「포장」이 뒤에 무언가가 더 남은 순서상의 한 지점으로 읽힙니다. 송장 번호는 발송 단계에서 붙고, 그때 관리자가 관리하는 목록에서 택배사도 함께 고릅니다. 각 택배사는 배송 조회 URL 템플릿을 갖고 있어서 가게가 입력한 번호가 구매자가 열 수 있는 링크가 됩니다. 택배사를 지정하지 않고 송장 번호만 붙일 수는 없습니다. 어디 번호인지 모르면 링크가 될 수 없기 때문입니다.
두 종류의 끝에는 모두 사유가 붙습니다. 취소하는 손님도 이유를 적어야 하고, 거절하는 가게도 이유를 적어야 하며, 그 이유는 채팅에서 흘러가 버리는 메시지가 아니라 주문에 함께 남습니다. 모든 전환은 양쪽에 알립니다. 알림이 없는 주문 흐름은 가게가 주문을 놓치고 구매자는 추측하게 만듭니다.
- 대기 — 접수됨, 가게의 응답을 기다리는 중
- 확인 — 수락됨, 가게가 약속한 상태
- 포장 — 준비 중
- 발송 — 출발, 송장 번호가 붙음
- 완료 — 종료, 그리고 구매자에게 리뷰 자격이 생김
판매자 쪽: 매장 POS, 온라인 주문, 재고
리테일 비즈니스는 세 개의 콘솔 타일을 얻습니다. 대면 판매를 위한 POS, 앱으로 들어온 것을 다루는 온라인 주문 콘솔, 그리고 부족 재고 경고가 있는 상품·재고 관리. 대시보드에는 몇 초마다 갱신되는 실시간 배지가 붙어서 새 주문이 발견되기를 기다리지 않고 스스로 알립니다. 매장에서 판 것과 앱으로 판 것은 같은 원장에 기록됩니다.
두 채널이 한곳에 모이니 리포트도 온라인 조각이 아니라 사업 전체를 덮습니다. 매출, 주문 수, 객단가, 현금·PromptPay·기타 결제 비중, 판매 상위 다섯 개, 7일 매출 막대 그래프, 그리고 오늘·7일·30일·전체 기간. 영수증은 재출력할 수 있고 같은 기록에서 공유용 영수증 문구를 만들 수 있습니다. 세금계산서는 발행할 수 없습니다 — 부가세 등록 사업자만 발행할 수 있는데, 이 시스템은 아직 어느 가게의 등록도 기록하고 있지 않기 때문입니다. 고객 명단도 같은 원장에서 만들어집니다. 고객별 메모와 태그, 구매 이력, 가게가 직접 정한 자체 화폐 기준 적립 비율의 포인트, 그리고 직접 정한 최소 결제액과 보상이 함께 있습니다.
직원 접근은 역할 프리셋과 사람별 예외가 있는 권한 행렬로 관리되므로, 판매 직원에게 급여나 리포트 없이 상품과 주문만 줄 수 있습니다. 같은 행렬이 데이터베이스에도 has_capability(business_id, capability)로 존재하며 행 수준 보안 정책을 위해 서버에서 프리셋을 펼칩니다. 서버는 누가 어떤 역할인지에 대해 클라이언트의 말을 받아들이지 않습니다.
상품 리뷰는 실제로 산 사람만 씁니다
상품 리뷰는 비즈니스 리뷰와 별개이고, 둘 다 같은 방식으로 잠겨 있습니다. 거래했다는 것을 플랫폼이 증명할 수 있는 손님만 쓸 수 있습니다. 자격은 데이터베이스 뷰이며, 구매는 매장 식사·배달 주문·숙박·서비스 이용과 나란히 놓인 자격 조건 중 하나입니다. 그 외의 사람에게는 실패하는 버튼 대신 왜 쓰기 버튼이 없는지 설명하는 잠금 카드가 보입니다.
별점은 누가 고치는 저장된 숫자가 아닙니다. 데이터베이스 트리거가 리뷰 행에서 다시 계산하므로 가게 카드의 평점과 페이지의 리뷰가 서로 어긋날 수 없습니다. 구매자는 가장 최근 리뷰를 수정하거나 삭제할 수 있고, 재구매하면 예전 리뷰를 남긴 채 새 리뷰를 추가할 수 있습니다. 가게는 공개적으로 답글을 답니다.
판 장비를 손님이 자산 대장에 등록해 두면, 고장 났을 때의 청구는 판 가게 — 그러니까 당신 쪽으로 돌아옵니다.
팔아 넘기는 것이 아니라 일·월 단위로 빌려주는 물건은 대여입니다. 홀 영업도 한다면 주문이 읽는 것은 같은 상품과 같은 재고입니다.