예약 포털 (Vue3 + Firebase) - 서비스 오픈까지

27. 예약 포털 (Vue3 + Vuetify + Firebase) - 카페의 온라인 주문 관리

그랜파 개발자 2025. 6. 13. 18:45

카페의 온라인 주문 관리

고객이 온라인 주문을 하면 주문은 대기 상태로 저장이 됩니다.

 

온라인 주문을 통해 저장된 고객의 주문을 확인하여

고객의 주문에 따른 음료의 준비가 끝나면 

주문에 대해 완료 버튼을 누르면 

주문 상태는 ‘완료’로 변경이 됩니다.

 

고객의 온라인 주문과 카페에서의 주문 완료 처리까지 시퀀스를 따라가 봅시다.

 

1. 카페 찾기

주문하고자 하는 카페를 찾아 ‘온라인 주문’을 합니다.

 

2. 주문 메뉴 선택 하기

음료와 토핑, 옵션을 선택한 후 ‘담기’를 누릅니다.

 

3. 주문하기

장바구니 버튼을 눌러 장바구니에서 주문 내역을 확인하고 

주문하기 버튼을 눌러 주문합니다.

 

 

- 주문번호 : 자동 증가하는 숫자

 

자동으로 증가하는 숫자로 주문번호를 부여하려고 합니다.

Firebase Firestore 같은 NoSQL DB는 기본적으로 자동 증가 필드를 지원하지 않으므로

자동 증가하는 숫자 주문번호 기능을 추가하려면,

Firestore 트랜잭션을 사용해 

companies/{companyId}/orderCounter 문서에서 마지막 주문번호를 읽고 +1 후 업데이트해야 합니다.

 

- src/composables/useOrder.js

// src/composables/useOrder.js
import { ref } from 'vue'
import { db } from '@/firebase'
import { 
  collection, 
  addDoc, 
  serverTimestamp, 
  query,
  orderBy,
  onSnapshot,
  doc,
  runTransaction,
  updateDoc,
} from 'firebase/firestore'

export function useOrder() {
  const loading = ref(false)
  const error = ref(null)
  const orders = ref([])

  /**
   * 회사(companyId) 하위 orders 서브컬렉션에 주문 저장 (자동 증가 주문번호 포함)
   * @param {string} companyId 
   * @param {object} orderData 
   */
  const createOrder = async (companyId, orderData) => {
    loading.value = true
    error.value = null
    try {
      const ordersColRef = collection(db, 'companies', companyId, 'orders')
      const counterDocRef = doc(db, 'companies', companyId, 'orderCounter', 'counter')

      let newOrderNumber = 0

      await runTransaction(db, async (transaction) => {
        const counterDoc = await transaction.get(counterDocRef)
        if (!counterDoc.exists()) {
          transaction.set(counterDocRef, { lastOrderNumber: 1 })
          newOrderNumber = 1
        } else {
          const lastOrderNumber = counterDoc.data().lastOrderNumber || 0
          newOrderNumber = lastOrderNumber + 1
          transaction.update(counterDocRef, { lastOrderNumber: newOrderNumber })
        }

        const payload = {
          ...orderData,
          createdAt: serverTimestamp(),
          status: '대기',
          orderNumber: newOrderNumber,
        }
        const newOrderDocRef = doc(ordersColRef)
        transaction.set(newOrderDocRef, payload)
      })

      loading.value = false
      return newOrderNumber
    } catch (e) {
      error.value = e
      loading.value = false
      throw e
    }
  }

  /** 
   * companyId의 orders 서브컬렉션 실시간 조회
   * @param {string} companyId 
   */
  const fetchOrdersRealtime = (companyId) => {
    loading.value = true
    error.value = null

    const ordersColRef = collection(db, 'companies', companyId, 'orders')
    const q = query(ordersColRef, orderBy('createdAt', 'desc'))

    const unsubscribe = onSnapshot(q, (snapshot) => {
      orders.value = snapshot.docs.map(doc => ({
        id: doc.id,
        ...doc.data()
      }))
      loading.value = false
    }, (e) => {
      error.value = e
      loading.value = false
    })

    return unsubscribe
  }

  /**
   * 주문 상태를 "완료"로 업데이트
   * @param {string} companyId 
   * @param {string} orderId 
   */
  const markAsCompleted = async (companyId, orderId) => {
    if (!companyId || !orderId) return

    try {
      const orderDocRef = doc(db, 'companies', companyId, 'orders', orderId)
      await updateDoc(orderDocRef, { status: '완료' })
    } catch (e) {
      error.value = e
      console.error('주문 완료 처리 실패:', e)
      throw e
    }
  }

  return {
    loading,
    error,
    orders,
    createOrder,
    fetchOrdersRealtime,
    markAsCompleted,
  }
}

 

Firestore에서 주문(order)을 생성하면서 주문번호를 자동 증가시키는 로직

- runTransaction Firebase Firestore에서 여러 읽기 및 쓰기 작업을 원자적으로 처리할 수 있도록 도와주는 함수로

모든 작업이 모두 성공하거나 모두 실패하게 만들어 데이터의 일관성을 보장합니다.

  /**
   * 회사(companyId) 하위 orders 서브컬렉션에 주문 저장 (자동 증가 주문번호 포함)
   * @param {string} companyId 
   * @param {object} orderData 
   */
  const createOrder = async (companyId, orderData) => {
    loading.value = true
    error.value = null
    try {
      const ordersColRef = collection(db, 'companies', companyId, 'orders')
      const counterDocRef = doc(db, 'companies', companyId, 'orderCounter', 'counter')

      let newOrderNumber = 0

      await runTransaction(db, async (transaction) => {
        const counterDoc = await transaction.get(counterDocRef)
        if (!counterDoc.exists()) {
          transaction.set(counterDocRef, { lastOrderNumber: 1 })
          newOrderNumber = 1
        } else {
          const lastOrderNumber = counterDoc.data().lastOrderNumber || 0
          newOrderNumber = lastOrderNumber + 1
          transaction.update(counterDocRef, { lastOrderNumber: newOrderNumber })
        }

        const payload = {
          ...orderData,
          createdAt: serverTimestamp(),
          status: '대기',
          orderNumber: newOrderNumber,
        }
        const newOrderDocRef = doc(ordersColRef)
        transaction.set(newOrderDocRef, payload)
      })

      loading.value = false
      return newOrderNumber
    } catch (e) {
      error.value = e
      loading.value = false
      throw e
    }
  }

 

🧩 코드 흐름 설명

1. 상태 초기화

loading.value = true
error.value = null
  • 로딩 시작, 에러 초기화

2. 컬렉션 및 문서 참조

const ordersColRef = collection(db, 'companies', companyId, 'orders')
const counterDocRef = doc(db, 'companies', companyId, 'orderCounter', 'counter')
  • 주문 저장할 위치와 주문 번호 카운터 문서를 지정해요.

3. Firestore 트랜잭션

await runTransaction(db, async (transaction) => {
  • 트랜잭션 시작: 이 안에서 읽고 쓰는 모든 작업은 원자적으로 처리돼요 (올-or-낫싱).

4. 현재 주문 번호 확인 및 증가

const counterDoc = await transaction.get(counterDocRef)

if (!counterDoc.exists()) {
  transaction.set(counterDocRef, { lastOrderNumber: 1 })
  newOrderNumber = 1
} else {
  const lastOrderNumber = counterDoc.data().lastOrderNumber || 0
  newOrderNumber = lastOrderNumber + 1
  transaction.update(counterDocRef, { lastOrderNumber: newOrderNumber })
}
  • 처음 주문이면 1번으로 시작하고,
  • 아니면 lastOrderNumber를 1 증가시켜서 주문번호를 설정합니다.

5. 실제 주문 데이터 구성 및 저장

const payload = {
  ...orderData,
  createdAt: serverTimestamp(),
  status: '대기',
  orderNumber: newOrderNumber,
}
  • 서버 타임스탬프, 초기 상태 대기, 주문 번호 포함해서 새 문서에 저장합니다.

4. 운영 대시보드

운영 대시보드에서 고객 주문 확인 메뉴로 고객 주문 확인 페이지로 이동합니다.

 

5. 주문 확인 및 완료 처리

고객 주문 확인 페이지에서 고객의 주문에 따라 음료가 준비되면 완료 버튼을 눌러 주문을 완료합니다.

주문확인 페이지의 경우 실제 카페에서 적용을 한다면 

바쁘게 음료를 준비하면서 화면을 확인하므로 시인성이 좋아야 합니다.

그래서 주문 내용이 잘 보일 수 있도록 각 항목에 대해 폰트를 키우고 글자색도 다르게 부여했습니다.

 

'완료' 버튼을 누르면 주문이 완료로 변경됩니다.

주문 상태 즉 대기, 완료의 태그 색을 다르게 하여 쉽게 구분할 수 있도록 했습니다.

 

 

주문 관리 컴포넌트

- src/views/OrderManager.vue

<!-- src/views/OrderManager.vue-->
<template>
  <v-container>
    <v-card>
      <!-- 운영 대시보드로 돌아가기 버튼 -->
      <div class="text-end mb-4 mr-2">
        <span
          class="text-primary text-subtitle-2 cursor-pointer"
          @click="goToDashboard"
        >
          운영 대시보드
        </span>
      </div>

      <v-card-title class="text-h5 font-weight-bold">{{companyName}} 주문 관리</v-card-title>
      <v-divider class="mb-4" />

      <v-card-text>
        <v-alert v-if="error" type="error" dense>
          오류: {{ error.message || error }}
        </v-alert>

        <v-progress-linear v-if="loading" indeterminate color="primary" class="mb-3" />

        <template v-if="orders.length">
          <div v-for="order in orders" :key="order.id" class="mb-6">
            <v-card>
              <v-card-title class="text-h6 font-weight-bold">
                주문번호: {{ order.orderNumber }} / 주문 ID: {{ order.id }}
                <v-spacer />
                <v-chip :color="getStatusColor(order.status)" dark>{{ order.status }}</v-chip>
              </v-card-title>

              <v-card-text>
                <p><strong>회원:</strong> {{ order.userName }}</p>
                <p><strong>총 금액:</strong> {{ order.totalAmount.toLocaleString() }}원</p>
                <p><strong>주문 일시:</strong> {{ formatDate(order.createdAt) }}</p>

                <v-divider class="my-4" />

                <strong class="text-h6 font-weight-medium">
                  주문 항목:
                </strong>


                <div class="mt-2">
                  <div
                    v-for="(item, index) in order.items"
                    :key="item.menuId + (item.option?.name || '')"
                    class="mb-4"
                  >
                    <div class="text-body-1 font-weight-medium">
                      {{ item.name }} - {{ item.option.name }}
                    </div>

                    <div v-if="item.toppings?.length" class="ml-2 mt-1 text-body-1 font-weight-medium">
                      <span v-if="item.toppings?.length" style="color: #0066cc;">
                        토핑: {{ item.toppings.map(t => t.name).join(', ') }}
                      </span>
                    </div>

                    <div class="ml-2 mt-1 text-body-1 font-weight-medium">
                      <span style="color: #CC5500;">
                        수량: {{ item.quantity }}
                      </span>
                    </div>

                    <!-- 항목 사이 분리선 (마지막 제외) -->
                    <v-divider v-if="index < order.items.length - 1" class="my-3" />
                  </div>
                </div>
              </v-card-text>
              <v-btn
                color="green"
                dark
                block
                @click="markAsCompletedHandler(order.id)"
                :disabled="order.status === '완료'"
              >
                완료
              </v-btn>

            </v-card>
          </div>
        </template>

        <v-alert v-else type="info">주문 내역이 없습니다.</v-alert>
      </v-card-text>
    </v-card>
  </v-container>
</template>

<script setup>
import { onMounted, onUnmounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrder } from '@/composables/useOrder'

const route = useRoute()
const router = useRouter()

const companyId = route.query.companyId
const companyName = route.query.companyName

const { loading, error, orders, fetchOrdersRealtime, markAsCompleted } = useOrder()
let unsubscribe = null

const goToDashboard = () => {
  router.push({
    name: 'OperationsDashboard',
    query: { companyId, companyName }
  })
}

onMounted(() => {
  if (companyId) {
    unsubscribe = fetchOrdersRealtime(companyId)
  } else {
    console.warn('companyId가 없습니다.')
  }
})

onUnmounted(() => {
  if (unsubscribe) unsubscribe()
})

function formatDate(timestamp) {
  if (!timestamp) return '-'
  return timestamp.toDate?.()?.toLocaleString() ?? (timestamp instanceof Date ? timestamp.toLocaleString() : '-')
}

function getStatusColor(status) {
  switch (status) {
    case '완료': return 'green'
    case '대기': return 'orange'
    case '취소': return 'red'
    default: return 'grey'
  }
}

const markAsCompletedHandler = async (orderId) => {
  if (!companyId) {
    console.warn('companyId가 없습니다.')
    return
  }
  try {
    await markAsCompleted(companyId, orderId)
  } catch (e) {
    console.error('주문 완료 처리 중 오류 발생:', e)
  }
}
</script>

 

 

준비가 완료되면 고객에게 픽업을 하도록 알려야 합니다.

FCM을 통해 고객에게 알리는 기능은 다음에 구현합니다.