Restaurant Order System

Complete LINE chatbot and order management system for restaurants

ระบบสั่งอาหารผ่าน LINE แบบครบวงจรสำหรับร้านอาหารในประเทศไทย

Project Overview

Client Background

ร้านอาหารชื่อดังในกรุงเทพฯ
├─ 5 สาขาทั่วกรุงเทพฯ
├─ 50+ เมนูอาหาร
├─ 200+ ออเดอร์ต่อวัน
└─ ปัญหา: การรับ order ผ่าน LINE ยุ่งยาก

Challenges Solved

❌ ปัญหาเดิม:
├─ จด order ลงกระดาษ ผิดพลาดบ่อย
├─ ไม่รู้ว่าสต็อกหมดหรือไม่
├─ ส่ง order ไปผิดสาขา
├─ ลูกค้าต้องรอนาน
└─ พนักงานทำงานหนักเกินไป

✅ วิธีแก้:
├─ LINE Chatbot รับ order อัตโนมัติ
├─ Real-time stock checking
├─ Auto-assign to nearest branch
├─ Instant order confirmation
└─ Kitchen display system

Technology Stack

Architecture Diagram

LINE OA → n8n (Railway) → Custom API (Vercel) → Supabase Database
    ↓           ↓                    ↓              ↓
Customer    Workflow            Business Logic    Data Storage
    ↓           ↓                    ↓              ↓
Confirmation → Kitchen Display ← Dashboard ← Analytics

Components Used

Frontend: Next.js + TailwindCSS (Vercel)
Backend: Node.js/TypeScript (Vercel Functions)
Database: Supabase (PostgreSQL)
Automation: n8n (Railway)
Real-time: Supabase Real-time + LINE Webhooks
Authentication: LINE Login

Key Features

1. Thai NLP for Food Ordering

// Thai food ordering NLP
const foodCategories = {
  rice: ['ข้าว', 'กะเพรา', 'ผัด', 'ราดหน้า', 'ข้าวผัด', 'ข้าวมันไก่'],
  noodles: ['ก๋วยเตี๋ยว', 'บะหมี่', 'เส้น', 'ราเมง', 'ยากิโซบะ', 'บะหมี่แด้'],
  drinks: ['น้ำ', 'เย็น', 'ชา', 'กาแฟ', 'โอเลี้ยง', 'โกโก้', 'โอวัลติน'],
  spicy: ['เผ็ด', 'พริก', 'ชาย', 'ต้มยำ', 'แกง', 'ต้ม'],
  not_spicy: ['ไม่เผ็ด', 'น้อย', 'หวาน', 'เผ็ดน้อย'],
  size: ['พิเศษ', 'ใหญ่', 'พอดี', 'เล็ก', 'พิเศษพิเศษ']
};

function parseFoodOrder(message) {
  const normalizedMessage = message.toLowerCase().trim();
  const items = [];
  
  // Detect food items
  for (const [category, keywords] of Object.entries(foodCategories)) {
    const matchedKeywords = keywords.filter(keyword => 
      normalizedMessage.includes(keyword)
    );
    
    if (matchedKeywords.length > 0) {
      items.push({
        category,
        keywords: matchedKeywords,
        confidence: matchedKeywords.length / keywords.length
      });
    }
  }
  
  // Extract quantities
  const quantities = normalizedMessage.match(/\d+/g) || [];
  
  // Extract special instructions
  const instructions = [];
  if (normalizedMessage.includes('ไม่ใส่')) instructions.push('exclude_ingredients');
  if (normalizedMessage.includes('พิเศษ')) instructions.push('extra_portion');
  if (normalizedMessage.includes('ร้อน')) instructions.push('hot_temperature');
  if (normalizedMessage.includes('เย็น')) instructions.push('cold_temperature');
  
  return {
    items,
    quantities: quantities.map(q => parseInt(q)),
    spicy_level: detectSpicyLevel(normalizedMessage),
    special_instructions: instructions,
    confidence: calculateConfidence(items, quantities)
  };
}

function detectSpicyLevel(message) {
  if (message.includes('เผ็ดมาก') || message.includes('ชายๆ')) return 'very_spicy';
  if (message.includes('เผ็ด')) return 'spicy';
  if (message.includes('ไม่เผ็ด') || message.includes('เผ็ดน้อย')) return 'mild';
  if (message.includes('ไม่เผ็ดเลย')) return 'not_spicy';
  return 'normal';
}

2. Real-time Order Processing

// n8n Function Node - Order Processing
async function processRestaurantOrder() {
  const orderData = $json;
  const parsedOrder = parseFoodOrder(orderData.message);
  
  try {
    // Get menu items from database
    const menuItems = await getMenuItems(parsedOrder.items);
    
    // Check stock availability
    const availableItems = await checkStockAvailability(menuItems);
    if (availableItems.unavailable.length > 0) {
      return [{
        json: {
          replyToken: orderData.replyToken,
          response: generateOutOfStockResponse(availableItems.unavailable),
          suggestions: await getAlternativeItems(availableItems.unavailable)
        }
      }];
    }
    
    // Calculate total amount
    const totalAmount = availableItems.available.reduce((sum, item) => 
      sum + (item.price * item.quantity), 0
    );
    
    // Determine nearest branch
    const branch = await determineNearestBranch(orderData.userLocation);
    
    // Generate order ID
    const orderId = `RES${Date.now()}${Math.random().toString(36).substr(2, 5).toUpperCase()}`;
    
    // Create order object
    const order = {
      order_id: orderId,
      customer_id: orderData.userId,
      items: availableItems.available,
      total_amount: totalAmount,
      branch_id: branch.id,
      branch_name: branch.name,
      status: 'pending',
      estimated_time: calculatePrepTime(availableItems.available),
      created_at: new Date().toISOString()
    };
    
    // Save to database
    const { data: savedOrder, error } = await supabase
      .from('orders')
      .insert(order)
      .select()
      .single();
    
    if (error) throw error;
    
    // Update stock
    await updateStock(availableItems.available);
    
    // Send to kitchen display
    await sendToKitchenDisplay(savedOrder);
    
    // Send confirmation to customer
    await sendOrderConfirmation(orderData.replyToken, savedOrder);
    
    return [{ json: savedOrder }];
    
  } catch (error) {
    console.error('Order processing error:', error);
    return [{
      json: {
        replyToken: orderData.replyToken,
        response: 'ขออภัยครับ เกิดข้อผิดพลาดในการประมวลผล กรุณาลองใหม่อีกครั้ง',
        error: error.message
      }
    }];
  }
}

async function checkStockAvailability(items) {
  const available = [];
  const unavailable = [];
  
  for (const item of items) {
    const { data: product } = await supabase
      .from('products')
      .select('id, name_th, price, stock, category')
      .eq('id', item.productId)
      .single();
    
    if (product && product.stock >= item.quantity) {
      available.push({
        ...product,
        quantity: item.quantity,
        subtotal: product.price * item.quantity
      });
    } else {
      unavailable.push({
        name: product?.name_th || item.name,
        requested: item.quantity,
        available: product?.stock || 0
      });
    }
  }
  
  return { available, unavailable };
}

3. Kitchen Display System

// Kitchen Display Component
export default function KitchenDisplay() {
  const [orders, setOrders] = useState([]);
  const [selectedBranch, setSelectedBranch] = useState('all');
  
  useEffect(() => {
    // Real-time subscription
    const subscription = supabase
      .channel('kitchen-orders')
      .on('postgres_changes', 
        { 
          event: '*', 
          schema: 'public', 
          table: 'orders',
          filter: `status=eq.pending`
        },
        (payload) => {
          if (payload.eventType === 'INSERT') {
            setOrders(prev => [...prev, payload.new]);
          } else if (payload.eventType === 'UPDATE') {
            setOrders(prev => prev.map(order => 
              order.id === payload.new.id ? payload.new : order
            ));
          }
        }
      )
      .subscribe();
    
    return () => subscription.unsubscribe();
  }, []);

  const filteredOrders = selectedBranch === 'all' 
    ? orders 
    : orders.filter(order => order.branch_id === selectedBranch);

  return (
    <div className="min-h-screen bg-gray-900 text-white p-4">
      <div className="max-w-7xl mx-auto">
        {/* Header */}
        <div className="flex justify-between items-center mb-6">
          <h1 className="text-3xl font-bold">ครัว - ออเดอร์ใหม่</h1>
          <div className="flex gap-4">
            <select 
              value={selectedBranch}
              onChange={(e) => setSelectedBranch(e.target.value)}
              className="bg-gray-800 text-white px-4 py-2 rounded"
            >
              <option value="all">ทุกสาขา</option>
              <option value="1">สาขา 1 - สุขุมวิท</option>
              <option value="2">สาขา 2 - สีลม</option>
              <option value="3">สาขา 3 - ทองหล่อ</option>
            </select>
            <div className="text-xl">
              ออเดอร์รอดำเนินการ: {filteredOrders.length}
            </div>
          </div>
        </div>

        {/* Orders Grid */}
        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          {filteredOrders.map(order => (
            <OrderCard 
              key={order.id} 
              order={order} 
              onUpdateStatus={updateOrderStatus}
            />
          ))}
        </div>
      </div>
    </div>
  );
}

function OrderCard({ order, onUpdateStatus }) {
  const [timer, setTimer] = useState(0);
  
  useEffect(() => {
    const interval = setInterval(() => {
      setTimer(Math.floor((Date.now() - new Date(order.created_at)) / 60000));
    }, 1000);
    
    return () => clearInterval(interval);
  }, [order.created_at]);

  const timeColor = timer > 15 ? 'text-red-400' : timer > 10 ? 'text-yellow-400' : 'text-green-400';

  return (
    <div className="bg-gray-800 rounded-lg p-4 border border-gray-700">
      {/* Order Header */}
      <div className="flex justify-between items-center mb-3">
        <div>
          <div className="font-bold text-lg">#{order.order_id}</div>
          <div className="text-sm text-gray-400">{order.branch_name}</div>
        </div>
        <div className={`text-2xl font-bold ${timeColor}`}>
          {timer} นาที
        </div>
      </div>

      {/* Order Items */}
      <div className="mb-4">
        {order.items.map((item, index) => (
          <div key={index} className="flex justify-between text-sm mb-1">
            <span>{item.name_th} x{item.quantity}</span>
            <span>฿{item.subtotal.toLocaleString()}</span>
          </div>
        ))}
      </div>

      {/* Order Total */}
      <div className="border-t border-gray-700 pt-2 mb-4">
        <div className="flex justify-between font-bold">
          <span>รวม</span>
          <span>฿{order.total_amount.toLocaleString()}</span>
        </div>
      </div>

      {/* Action Buttons */}
      <div className="flex gap-2">
        <button
          onClick={() => onUpdateStatus(order.id, 'preparing')}
          className="flex-1 bg-blue-600 hover:bg-blue-700 px-3 py-2 rounded text-sm"
        >
          เริ่มทำ
        </button>
        <button
          onClick={() => onUpdateStatus(order.id, 'ready')}
          className="flex-1 bg-green-600 hover:bg-green-700 px-3 py-2 rounded text-sm"
        >
          เสร็จแล้ว
        </button>
        <button
          onClick={() => onUpdateStatus(order.id, 'cancelled')}
          className="flex-1 bg-red-600 hover:bg-red-700 px-3 py-2 rounded text-sm"
        >
          ยกเลิก
        </button>
      </div>
    </div>
  );
}

4. Customer Dashboard

// Customer Order Tracking Dashboard
export default function CustomerDashboard() {
  const [user, setUser] = useState(null);
  const [orders, setOrders] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    // Initialize LINE Login
    liff.init({ liffId: process.env.NEXT_PUBLIC_LIFF_ID })
      .then(() => {
        if (liff.isLoggedIn()) {
          const profile = liff.getDecodedIDToken();
          setUser(profile);
          loadUserOrders(profile.sub);
        } else {
          liff.login();
        }
      });
  }, []);

  async function loadUserOrders(userId) {
    try {
      const { data: orders } = await supabase
        .from('orders')
        .select(`
          *,
          branches (name, address, phone)
        `)
        .eq('customer_id', userId)
        .order('created_at', { ascending: false })
        .limit(10);

      setOrders(orders || []);
    } catch (error) {
      console.error('Load orders error:', error);
    } finally {
      setLoading(false);
    }
  }

  if (loading) {
    return <LoadingSpinner />;
  }

  return (
    <div className="min-h-screen bg-gray-50 p-4">
      <div className="max-w-4xl mx-auto">
        {/* User Profile */}
        <div className="bg-white rounded-lg shadow p-6 mb-6">
          <div className="flex items-center gap-4">
            <img 
              src={user.picture} 
              alt={user.name}
              className="w-16 h-16 rounded-full"
            />
            <div>
              <h1 className="text-2xl font-bold">{user.name}</h1>
              <p className="text-gray-600">ยินดีต้อนรับกลับมาครับ!</p>
            </div>
          </div>
        </div>

        {/* Quick Actions */}
        <div className="grid grid-cols-2 gap-4 mb-6">
          <button className="bg-orange-500 text-white p-4 rounded-lg font-bold">
            🍛 สั่งอาหาร
          </button>
          <button className="bg-blue-500 text-white p-4 rounded-lg font-bold">
            📋 ดูประวัติ
          </button>
        </div>

        {/* Recent Orders */}
        <div className="bg-white rounded-lg shadow p-6">
          <h2 className="text-xl font-bold mb-4">ออเดอร์ล่าสุด</h2>
          
          {orders.length === 0 ? (
            <div className="text-center py-8 text-gray-500">
              ยังไม่มีประวัติการสั่งซื้อ
            </div>
          ) : (
            <div className="space-y-4">
              {orders.map(order => (
                <OrderCard key={order.id} order={order} />
              ))}
            </div>
          )}
        </div>
      </div>
    </div>
  );
}

function OrderCard({ order }) {
  const statusColors = {
    pending: 'bg-yellow-100 text-yellow-800',
    preparing: 'bg-blue-100 text-blue-800',
    ready: 'bg-green-100 text-green-800',
    completed: 'bg-gray-100 text-gray-800',
    cancelled: 'bg-red-100 text-red-800'
  };

  const statusTexts = {
    pending: 'รอดำเนินการ',
    preparing: 'กำลังเตรียม',
    ready: 'พร้อมรับ',
    completed: 'สำเร็จแล้ว',
    cancelled: 'ยกเลิก'
  };

  return (
    <div className="border border-gray-200 rounded-lg p-4">
      <div className="flex justify-between items-start mb-2">
        <div>
          <div className="font-bold">#{order.order_id}</div>
          <div className="text-sm text-gray-600">{order.branches.name}</div>
        </div>
        <span className={`px-2 py-1 rounded-full text-xs font-medium ${statusColors[order.status]}`}>
          {statusTexts[order.status]}
        </span>
      </div>
      
      <div className="text-sm text-gray-600 mb-2">
        {formatThaiDateTime(order.created_at)}
      </div>
      
      <div className="flex justify-between items-center">
        <span className="font-bold">฿{order.total_amount.toLocaleString()}</span>
        <button className="text-blue-600 text-sm hover:underline">
          ดูรายละเอียด
        </button>
      </div>
    </div>
  );
}

Implementation Timeline

Phase 1: Foundation (Week 1-2)

✅ เสร็จสิ้น:
├─ Setup Supabase database
├─ Create n8n workflow foundation
├─ Implement LINE webhook
├─ Basic order processing
└─ Thai NLP prototype

Phase 2: Core Features (Week 3-4)

✅ เสร็จสิ้น:
├─ Complete order processing
├─ Stock management system
├─ Kitchen display system
├─ Customer dashboard
└─ Real-time updates

Phase 3: Advanced Features (Week 5-6)

✅ เสร็จสิ้น:
├─ Analytics dashboard
├─ Loyalty program
├─ Payment integration
├─ Multi-branch management
└─ Mobile optimization

Results & Metrics

Before vs After

📊 ผลลัพธ์หลัง 3 เดือน:

Order Processing:
├─ Error rate: 85% ↓ 5%
├─ Processing time: 15 นาที ↓ 5 นาที
├─ Customer satisfaction: 60% ↑ 95%
└─ Staff workload: -40%

Business Impact:
├─ Daily orders: 200 ↑ 650
├─ Revenue: +125%
├─ Customer retention: +60%
└─ New branches: 2 สาขาเพิ่มเติม

Customer Feedback

💬 ความคิดเห็นลูกค้า:
├─ "สั่งง่ายขึ้นเยอะ ไม่ต้องโทรแล้ว"
├─ "ได้อาหารเร็วกว่าเดิม"
├─ "รู้ว่าออเดอร์ถึงไหนแล้ว"
├─ "สต็อกหมดแจ้งล่วงหน้าดี"
└─ "แถมมีแต้มสะสมด้วย"

Staff Feedback

👨‍🍳 ความคิดเห็นพนักงาน:
├─ "ทำงานง่ายขึ้น ไม่ต้องจด"
├─ "รู้ว่าลูกค้าอยากได้อะไร"
├─ "สต็อกไม่พลาดแล้ว"
├─ "สื่อสารกับลูกค้าดีขึ้น"
└─ "มีเวลาทำอาหารมากขึ้น"

Cost Analysis

Investment Breakdown

💰 การลงทุน (ครั้งเดียว):
├─ Development: ฿45,000
├─ Setup & Configuration: ฿15,000
├─ Training: ฿10,000
├─ Testing & QA: ฿10,000
└─ Total: ฿80,000

💸 ค่าใช้จ่ายรายเดือน:
├─ n8n hosting (Railway): ฿500
├─ Database (Supabase): ฿800
├─ Frontend (Vercel): ฿300
├─ LINE OA Premium: ฿1,500
└─ Total: ฿3,100/เดือน

ROI Calculation

📈 Return on Investment:
├─ Monthly revenue increase: ฿125,000
├─ Monthly cost savings: ฿25,000
├─ Net monthly gain: ฿146,900
├─ Payback period: 0.5 เดือน
└─ Annual ROI: 1,768%

Technical Challenges & Solutions

Challenge 1: Thai Language Processing

❌ ปัญหา:
├─ ศัพท์สำหนวนแต่ละคนไม่เหมือนกัน
├─ การสะกดคำผิด
├─ การย่อคำ (เช่น "กะเพร" แทน "กะเพรา")
└─ การใช้ emoji และ sticker

✅ วิธีแก้:
├─ สร้าง dictionary ของศัพท์อาหารไทย
├─ ใช้ fuzzy matching สำหรับคำผิด
├─ เรียนรู้จากข้อมูลจริง
└─ ให้ AI ช่วยแนะนำเมนู

Challenge 2: Real-time Stock Management

❌ ปัญหา:
├─ สต็อกอาจหมดตอนที่ลูกค้าสั่ง
├─ การอัปเดตสต็อกจากหลายจุด
├─ Race conditions ในระบบ
└─ การสำรองสินค้า

✅ วิธีแก้:
├─ Real-time stock checking
├─ Row-level security ใน Supabase
├─ Database transactions
└─ Auto-reorder alerts

Challenge 3: Multi-branch Coordination

❌ ปัญหา:
├─ การส่งออเดอร์ไปผิดสาขา
├─ การจัดการสต็อกแยกตามสาขา
├─ การรายงานรวม
└─ การส่งมอบระหว่างสาขา

✅ วิธีแก้:
├─ Geolocation-based branch assignment
├─ Branch-specific inventory
├─ Centralized reporting
└─ Inter-branch transfer system

Lessons Learned

Technical Lessons

✅ Do's:
├─ เริ่มจาก MVP แล้วค่อยๆ พัฒนา
├─ ทดสอบกับข้อมูลจริงตั้งแต่แรก
├─ ใช้ real-time subscriptions อย่างมีประสิทธิภาพ
├─ วางแผน scalability ตั้งแต่เริ่ม
└─ Monitoring และ logging ที่ดี

❌ Don'ts:
├─ ไม่คิดถึง edge cases
├─ ลืม error handling
├─ ไม่ทดสอบกับผู้ใช้จริง
├─ ใช้ technology ซับซ้อนเกินไป
└─ ไม่มี backup plan

Business Lessons

✅ Success Factors:
├─ เข้าใจ workflow ของร้านอาหารจริง
├─ ฟังความต้องการของลูกค้า
├─ สร้างระบบที่ใช้งานง่าย
├─ สนับสนุนพนักงานให้เปลี่ยนแปลง
└─ วัดผลและปรับปรุงอย่างต่อเนื่อง

Future Enhancements

Phase 4: Advanced Features (Planned)

🚀 คุณสมบัติที่จะเพิ่ม:
├─ AI-powered menu recommendations
├─ Voice ordering capabilities
├─ Delivery tracking integration
├─ Advanced analytics & insights
├─ Supplier management system
└─ Mobile app for staff

Expansion Plans

📈 แผนการขยาย:
├─ เพิ่มสาขาในต่างจังหวัด
├─ Franchise system support
├─ Multi-language support
├─ Integration with food delivery apps
└─ White-label solution for other restaurants

ต้องการระบบสั่งอาหารแบบนี้หรือไม่? ติดต่อเราได้ที่ ShantiLink.com 💬

ดูตัวอย่างอื่นๆ: