Desain Prisma Schema buat Sistem F&B Multi-Outlet: dari Nol sampai Production
Menu beda harga per outlet, add-on yang availability-nya beda-beda, stok yang harus bisa diaudit, dan order yang menghubungkan semuanya. Ini breakdown langkah demi langkah bagaimana mendesain Prisma schema untuk kasus F&B yang kompleks.
Kali ini gue mau bahas sesuatu yang sering bikin pusing kalau bikin sistem F&B: desain data model buat multi-outlet.
Bayangin lo punya bisnis F&B yang udah punya beberapa cabang. Menu utama sama, tapi harga bisa beda per lokasi. Add-on (topping, extra sauce) juga beda ketersediaannya per cabang. Stok harus dilacak per item per lokasi. Dan order harus menghubungkan customer, menu, add-on, outlet, dan payment dalam satu kesatuan.
Ini bukan sekadar relasi many-to-many biasa. Ada nuansa yang bikin data model-nya nggak trivial.
Gue bakal breakdown langkah demi langkah — dari mapping requirement sampai query yang sebenarnya jalan di production.
Struktur File yang Digunakan
prisma/ │ ├── schema.prisma │ └── migrations/ lib/ │ └── generated/ │ └── prisma/ app/ ├── api/ │ ├── orders/route.ts │ ├── menu/route.ts │ └── stock/route.ts ├── admin/ │ ├── components/ │ └── dashboard-menu/ └── services/ ├── orderService.ts └── stockService.ts
Langkah 1: Map Relationship Dulu, Coding Nanti
Pelajaran terbesar gue di proyek kayak gini: jangan langsung buka editor dan nulis model di Prisma. Duduk dulu, gambar ERD di kertas.
Requirement yang biasa muncul di sistem F&B:
- Satu menu bisa punya harga beda di tiap outlet
- Setiap menu punya add-on (topping, extra sauce, upgrade size)
- Add-on juga punya ketersediaan dan harga beda per outlet
- Stok bukan cuma angka — harus bisa di-audit (siapa yang ubah, kapan, kenapa)
- Order menghubungkan customer, menu, add-on, dan outlet
- User punya role (SUPERADMIN, ADMIN, KASIR) dan bisa di-assign ke outlet tertentu
Dari mapping itu, lo bakal nemuin pola utama: junction table dengan field tambahan. Pola ini bakal muncul berulang kali di seluruh schema.
Langkah 2: Core Models — Category dan Menu
Mulai dari yang simpel. Category mengelompokkan menu:
model Category {
id String @id @default(uuid())
name String @unique
status String @default("active")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
addOns AddOn[]
menus Menu[]
}Menu punya field dasar tapi harga bukan property utamanya. Kenapa? Karena harga sebenarnya ditentukan oleh relasi Menu-Outlet, bukan oleh Menu sendiri:
model Menu {
id String @id @default(uuid())
name String
price Int // harga default/base
description String?
imageUrl String?
status String @default("active")
stock Int @default(0)
categoryId String @map("category_id")category Category @relation(fields: [categoryId], references: [id]) orderItems OrderItem[] stockLogs StockLog[] menuOutlets MenuOutlet[] // relasi ke outlet addOnItems OrderAddOn[] } ```
Field price di sini adalah base price — harga default. Harga sebenarnya ditentukan di MenuOutlet.
Langkah 3: Junction Table — Harga Beda per Outlet
Ini pattern yang paling penting di seluruh schema. Menu dan Outlet punya relasi many-to-many dengan field tambahan:
model MenuOutlet {
id String @id @default(uuid())
menuId String @map("menu_id")
outletId String @map("outlet_id")
price Int // harga spesifik per outlet
status String @default("active")
isArchive Boolean @default(false)menu Menu @relation(fields: [menuId], references: [id]) outlet Outlet @relation(fields: [outletId], references: [id])
@@unique([menuId, outletId]) // satu menu cuma bisa sekali per outlet } ```
Pattern yang sama dipakai untuk AddOn:
model AddOnOutlet {
id String @id @default(uuid())
addOnId String @map("addon_id")
outletId String @map("outlet_id")
price Int // harga add-on spesifik per outlet
isActive Boolean @default(true)addOn AddOn @relation(fields: [addOnId], references: [id]) outlet Outlet @relation(fields: [outletId], references: [id]) } ```
Dengan pattern ini, lo bisa query harga yang benar untuk menu tertentu di outlet tertentu tanpa bikin duplikasi data.
Contoh query buat ambil menu dengan harga per outlet:
const menuWithPrices = await prisma.menuOutlet.findMany({
where: { outletId: 'outlet-abc', isActive: true },
include: { menu: true },
})Langkah 4: Stok yang Bisa Diaudit
Stok itu sensitif. Kalau ada selisih, owner harus bisa trace kapan dan kenapa angkanya berubah. Jadi gue nggak simpan stok sebagai angka statis — gue simpan sebagai log:
model StockLog {
id String @id @default(uuid())
menuId String @map("menu_id")
change Int // positif = masuk, negatif = keluar
reason String // "order", "restock", "waste", "adjustment"
createdAt DateTime @default(now())menu Menu @relation(fields: [menuId], references: [id]) } ```
Stok aktual dihitung dari SUM of all StockLog. Ini artinya setiap perubahan tercatat, dan lo bisa audit kapan saja.
Query untuk cek stok aktual:
const stock = await prisma.stockLog.aggregate({
_sum: { change: true },
where: { menuId: 'menu-uuid' },
})const currentStock = stock._sum.change || 0 ```
Simple, tapi powerful. Tidak ada angka ajaib yang nggak jelas asalnya.
Langkah 5: Order — Hubungkan Semuanya
Order adalah model yang menghubungkan customer, menu, add-on, outlet, dan payment:
model Order {
id String @id @default(cuid())
transactionCode String @unique
queueNumber Int?
customerName String
customerPhone String
totalAmount Int
status OrderStatus @default(PENDING)
orderSource OrderSource @default(CUSTOMER)
paymentStatus PaymentStatus @default(PENDING)
outletId String?orderItems OrderItem[] stockLogs StockLog[] outlet Outlet? @relation(fields: [outletId], references: [id]) } ```
OrderItem menyimpan detail per item, dan OrderAddOn menyimpan add-on per item:
model OrderItem {
id String @id @default(uuid())
orderId String @map("order_id")
menuId String @map("menu_id")
quantity Int
price Int // snapshot harga saat orderorder Order @relation(fields: [orderId], references: [id]) menu Menu @relation(fields: [menuId], references: [id]) addOns OrderAddOn[] }
model OrderAddOn { id String @id @default(uuid()) orderItemId String @map("order_item_id") addOnId String @map("addon_id") quantity Int price Int // snapshot harga saat order
orderItem OrderItem @relation(fields: [orderItemId], references: [id]) addOn AddOn @relation(fields: [addOnId], references: [id]) } ```
Hal penting: price disimpan di OrderItem dan OrderAddOn. Ini snapshot — lo harus menyimpan harga saat transaksi, bukan reference ke tabel Menu. Kalau lo cuma reference, lalu harga berubah besok, laporan keuangan hari ini jadi salah.
Langkah 6: Enum untuk Status dan Role
Gunakan enum, bukan string bebas. Ini memastikan cuma value yang valid yang masuk ke database:
enum UserRole {
SUPERADMIN
ADMIN
KASIR
}enum OrderStatus { PENDING PROCESSING COMPLETED CANCELLED }
enum PaymentStatus { PENDING PAID FAILED REFUNDED }
enum OrderSource { CUSTOMER POS } ```
Tips Terakhir
Habiskan 70% waktu lo untuk desain data model, 30% untuk coding. Kalau relasinya salah dari awal, refactoring di Prisma itu menyakitkan. Mapping di kertas dulu, baru coding.
Jangan takut junction table. Pattern MenuOutlet itu terlihat verbose, tapi itu cara yang benar buat handle data yang berbeda per relasi. Nggak ada shortcut yang clean.
Snapshot harga di order. Ini yang sering terlupa. Kalau lo reference ke Menu.price, lalu harga berubah, laporan keuangan lo jadi nggak akurat. Selalu simpan harga saat transaksi.