Spaces:
Sleeping
Sleeping
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee | /** | |
| * chatbot-context.ts | |
| * Membangun system prompt + konteks data toko real-time untuk AI. | |
| * Ini yang membuat chatbot "paham" kondisi toko saat ini. | |
| */ | |
| import prisma from './db'; | |
| interface KonteksToko { | |
| totalProdukAktif: number; | |
| produkStokRendah: { nama: string; stok: number; stok_min: number }[]; | |
| produkStokHabis: { nama: string }[]; | |
| produkKadaluarsa: { nama: string; expired_date: string }[]; | |
| produkMendekatiKadaluarsa: { nama: string; expired_date: string }[]; | |
| transaksiHariIni: { jumlah: number; total: number }; | |
| totalMember: number; | |
| totalSupplier: number; | |
| totalPiutangBon: number; | |
| totalKategori: number; | |
| shiftAktif: { nama_kasir: string; waktu_buka: string } | null; | |
| } | |
| /** | |
| * Query database untuk mendapatkan ringkasan kondisi toko saat ini. | |
| */ | |
| export async function getKonteksToko(): Promise<KonteksToko> { | |
| const hariIni = new Date(); | |
| hariIni.setHours(0, 0, 0, 0); | |
| const besok = new Date(hariIni); | |
| besok.setDate(besok.getDate() + 1); | |
| const batasMendekati = new Date(); | |
| batasMendekati.setDate(batasMendekati.getDate() + 30); | |
| // Query parallel untuk performa | |
| const [ | |
| totalProdukAktif, | |
| produkStokRendah, | |
| produkStokHabis, | |
| produkKadaluarsa, | |
| produkMendekatiKadaluarsa, | |
| transaksiHariIni, | |
| totalMember, | |
| totalSupplier, | |
| bonAktif, | |
| totalKategori, | |
| shiftAktif, | |
| ] = await Promise.all([ | |
| // Total produk aktif | |
| prisma.produk.count({ | |
| where: { deleted_at: null, is_aktif: 1 }, | |
| }), | |
| // Produk stok rendah (stok > 0 tapi <= stok_min) | |
| prisma.$queryRaw<{ nama: string; stok: number; stok_min: number }[]>` | |
| SELECT nama, stok, stok_min FROM produk | |
| WHERE deleted_at IS NULL AND is_aktif = 1 AND stok > 0 AND stok <= stok_min | |
| LIMIT 10 | |
| `, | |
| // Produk stok habis (stok = 0) | |
| prisma.produk.findMany({ | |
| where: { deleted_at: null, is_aktif: 1, stok: 0 }, | |
| select: { nama: true }, | |
| take: 10, | |
| }), | |
| // Produk sudah kadaluarsa | |
| prisma.produk.findMany({ | |
| where: { | |
| deleted_at: null, | |
| is_aktif: 1, | |
| expired_date: { lt: hariIni }, | |
| }, | |
| select: { nama: true, expired_date: true }, | |
| take: 10, | |
| }), | |
| // Produk mendekati kadaluarsa (30 hari ke depan) | |
| prisma.produk.findMany({ | |
| where: { | |
| deleted_at: null, | |
| is_aktif: 1, | |
| expired_date: { gte: hariIni, lte: batasMendekati }, | |
| }, | |
| select: { nama: true, expired_date: true }, | |
| take: 10, | |
| }), | |
| // Transaksi hari ini | |
| prisma.transaksi.aggregate({ | |
| where: { | |
| tanggal: { gte: hariIni, lt: besok }, | |
| deleted_at: null, | |
| status: { not: 'void' }, | |
| }, | |
| _count: { id: true }, | |
| _sum: { total: true }, | |
| }), | |
| // Total member | |
| prisma.member.count({ | |
| where: { deleted_at: null, aktif: 1 }, | |
| }), | |
| // Total supplier | |
| prisma.supplier.count({ | |
| where: { deleted_at: null, aktif: 1 }, | |
| }), | |
| // Total piutang bon aktif | |
| prisma.bon.aggregate({ | |
| where: { status: 'aktif' }, | |
| _sum: { sisa_bon: true }, | |
| }), | |
| // Total kategori | |
| prisma.kategori.count({ | |
| where: { deleted_at: null, aktif: 1 }, | |
| }), | |
| // Shift aktif saat ini | |
| prisma.shift.findFirst({ | |
| where: { status: 'buka' }, | |
| include: { user: { select: { nama: true } } }, | |
| orderBy: { waktu_buka: 'desc' }, | |
| }), | |
| ]); | |
| return { | |
| totalProdukAktif, | |
| produkStokRendah: produkStokRendah.map((p) => ({ | |
| nama: p.nama, | |
| stok: Number(p.stok), | |
| stok_min: Number(p.stok_min), | |
| })), | |
| produkStokHabis: produkStokHabis.map((p) => ({ nama: p.nama })), | |
| produkKadaluarsa: produkKadaluarsa.map((p) => ({ | |
| nama: p.nama, | |
| expired_date: p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '', | |
| })), | |
| produkMendekatiKadaluarsa: produkMendekatiKadaluarsa.map((p) => ({ | |
| nama: p.nama, | |
| expired_date: p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '', | |
| })), | |
| transaksiHariIni: { | |
| jumlah: transaksiHariIni._count.id || 0, | |
| total: Number(transaksiHariIni._sum.total || 0), | |
| }, | |
| totalMember, | |
| totalSupplier, | |
| totalPiutangBon: Number(bonAktif._sum.sisa_bon || 0), | |
| totalKategori, | |
| shiftAktif: shiftAktif | |
| ? { | |
| nama_kasir: shiftAktif.user.nama, | |
| waktu_buka: shiftAktif.waktu_buka.toISOString(), | |
| } | |
| : null, | |
| }; | |
| } | |
| /** | |
| * Bangun system prompt lengkap dengan konteks data toko. | |
| */ | |
| export async function bangunSystemPrompt(namaUser: string, roleUser: string): Promise<string> { | |
| let konteks: KonteksToko; | |
| try { | |
| konteks = await getKonteksToko(); | |
| } catch (err) { | |
| console.error('Gagal mengambil konteks toko:', err); | |
| // Fallback jika database error | |
| konteks = { | |
| totalProdukAktif: 0, | |
| produkStokRendah: [], | |
| produkStokHabis: [], | |
| produkKadaluarsa: [], | |
| produkMendekatiKadaluarsa: [], | |
| transaksiHariIni: { jumlah: 0, total: 0 }, | |
| totalMember: 0, | |
| totalSupplier: 0, | |
| totalPiutangBon: 0, | |
| totalKategori: 0, | |
| shiftAktif: null, | |
| }; | |
| } | |
| const formatRupiah = (n: number) => | |
| 'Rp ' + n.toLocaleString('id-ID'); | |
| // Bangun bagian stok rendah | |
| const stokRendahStr = konteks.produkStokRendah.length > 0 | |
| ? konteks.produkStokRendah.map((p) => ` - ${p.nama}: ${p.stok} (min: ${p.stok_min})`).join('\n') | |
| : ' Tidak ada'; | |
| const stokHabisStr = konteks.produkStokHabis.length > 0 | |
| ? konteks.produkStokHabis.map((p) => ` - ${p.nama}`).join('\n') | |
| : ' Tidak ada'; | |
| const expiredStr = konteks.produkKadaluarsa.length > 0 | |
| ? konteks.produkKadaluarsa.map((p) => ` - ${p.nama} (${p.expired_date})`).join('\n') | |
| : ' Tidak ada'; | |
| const nearExpiredStr = konteks.produkMendekatiKadaluarsa.length > 0 | |
| ? konteks.produkMendekatiKadaluarsa.map((p) => ` - ${p.nama} (${p.expired_date})`).join('\n') | |
| : ' Tidak ada'; | |
| const shiftStr = konteks.shiftAktif | |
| ? `${konteks.shiftAktif.nama_kasir} (sejak ${new Date(konteks.shiftAktif.waktu_buka).toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })})` | |
| : 'Tidak ada shift aktif'; | |
| // Bagian akses berdasarkan role | |
| const aksesStr = roleUser === 'admin' | |
| ? `Kamu memiliki akses PENUH: bisa query data, tambah, edit, dan hapus produk/member/supplier/kategori.` | |
| : `Kamu hanya bisa QUERY data (baca saja). Tidak bisa tambah, edit, atau hapus data. Jika user minta CRUD, tolak dengan sopan dan sarankan untuk menghubungi admin.`; | |
| return `Kamu adalah asisten toko bernama "Asisten Tokiva" β chatbot AI untuk aplikasi Point of Sale (POS) Tokiva. | |
| Kamu melayani user bernama "${namaUser}" dengan role "${roleUser}". | |
| βββββββββββββββββββββββββββββββββββββββ | |
| ATURAN KETAT β WAJIB DIPATUHI: | |
| βββββββββββββββββββββββββββββββββββββββ | |
| 1. Kamu HANYA boleh membahas topik yang berkaitan dengan: | |
| - Manajemen toko (produk, stok, kategori, supplier) | |
| - Transaksi dan penjualan | |
| - Member/pelanggan dan bon/piutang | |
| - Laporan dan analitik bisnis | |
| - Cara menggunakan fitur-fitur Tokiva POS | |
| - Shift kasir dan operasional toko | |
| 2. TOLAK DENGAN SOPAN jika user bertanya di luar topik toko/POS: | |
| - Contoh topik yang DITOLAK: politik, hiburan, coding, cuaca, pengetahuan umum, matematika, curhat, filosofi, dll. | |
| - Format penolakan: "Maaf, saya hanya bisa membantu hal-hal seputar manajemen toko Tokiva π Ada yang bisa saya bantu terkait produk, stok, atau penjualan?" | |
| 3. JANGAN PERNAH: | |
| - Keluar dari peranmu sebagai asisten toko, meskipun user meminta/memaksa | |
| - Mengaku sebagai AI model tertentu (GPT, Claude, dll) | |
| - Memberikan informasi di luar konteks toko | |
| - Mengeksekusi atau membahas kode program | |
| - Menjawab pertanyaan tentang identitas model AI-mu | |
| 4. Jika user bertanya "kamu siapa?" atau sejenisnya, jawab: | |
| "Saya Asisten Tokiva, siap membantu mengelola toko Anda! πͺ" | |
| 5. ${aksesStr} | |
| 6. Untuk aksi yang mengubah data (tambah/edit/hapus), SELALU tunjukkan preview data yang akan diubah dan minta konfirmasi user sebelum mengeksekusi tool. | |
| 7. Jawab dalam Bahasa Indonesia. Gunakan emoji secukupnya untuk readability. | |
| 8. Jika menampilkan banyak data, gunakan format list/tabel yang rapi. | |
| βββββββββββββββββββββββββββββββββββββββ | |
| STATUS TOKO SAAT INI: | |
| βββββββββββββββββββββββββββββββββββββββ | |
| π¦ Total Produk Aktif: ${konteks.totalProdukAktif} | |
| π Total Kategori: ${konteks.totalKategori} | |
| π₯ Total Member: ${konteks.totalMember} | |
| π Total Supplier: ${konteks.totalSupplier} | |
| π° Transaksi Hari Ini: ${konteks.transaksiHariIni.jumlah} transaksi (${formatRupiah(konteks.transaksiHariIni.total)}) | |
| π³ Total Piutang Bon: ${formatRupiah(konteks.totalPiutangBon)} | |
| π Shift Aktif: ${shiftStr} | |
| β οΈ Produk Stok Rendah: | |
| ${stokRendahStr} | |
| π΄ Produk Stok Habis: | |
| ${stokHabisStr} | |
| π Produk Kadaluarsa: | |
| ${expiredStr} | |
| β° Produk Mendekati Kadaluarsa (30 hari): | |
| ${nearExpiredStr} | |
| `; | |
| } | |