/** * chatbot-tools.ts * Definisi tools (function calling) yang AI bisa panggil untuk berinteraksi dengan database. * Setiap tool memanggil existing service layer — tidak akses DB langsung. */ import type { ChatCompletionTool } from 'openai/resources/chat/completions'; import prisma from './db'; import { ProdukService } from '../services/produk.service'; import { MemberService } from '../services/member.service'; import { SupplierService } from '../services/supplier.service'; import { KategoriService } from '../services/kategori.service'; import { BonService } from '../services/bon.service'; import { StokService } from '../services/stok.service'; import { OpsService } from '../services/ops.service'; import { TransaksiService } from '../services/transaksi.service'; import { generateKodeProduk, generateKodeMember } from './kodeGenerator'; import { catatAudit } from './audit'; // ═══════════════════════════════════ // INTERFACE // ═══════════════════════════════════ export interface ToolHandler { nama: string; butuhKonfirmasi: boolean; adminOnly: boolean; handler: (params: any, userId: number, ip: string, userAgent: string) => Promise; } // ═══════════════════════════════════ // TOOL DEFINITIONS (untuk dikirim ke AI) // ═══════════════════════════════════ export const TOOL_DEFINITIONS: ChatCompletionTool[] = [ // ── Produk ── { type: 'function', function: { name: 'cari_produk', description: 'Cari produk berdasarkan nama, kode, atau barcode. Tampilkan hasil pencarian.', parameters: { type: 'object', properties: { pencarian: { type: 'string', description: 'Kata kunci pencarian (nama produk, kode, atau barcode)' }, }, required: ['pencarian'], }, }, }, { type: 'function', function: { name: 'detail_produk', description: 'Lihat detail lengkap satu produk berdasarkan ID produk.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID produk' }, }, required: ['id'], }, }, }, { type: 'function', function: { name: 'tambah_produk', description: 'Tambahkan produk baru ke database. Kode produk akan di-generate otomatis jika tidak diisi.', parameters: { type: 'object', properties: { nama: { type: 'string', description: 'Nama produk' }, harga_jual: { type: 'number', description: 'Harga jual (Rupiah)' }, harga_beli: { type: 'number', description: 'Harga beli/modal (Rupiah)' }, kategori_id: { type: 'number', description: 'ID kategori produk' }, satuan: { type: 'string', description: 'Satuan produk (pcs, kg, liter, pack, dll). Default: pcs' }, barcode: { type: 'string', description: 'Barcode produk (opsional)' }, stok_min: { type: 'number', description: 'Stok minimum sebelum warning. Default: 5' }, stok: { type: 'number', description: 'Stok awal (opsional, default: 0)' }, }, required: ['nama', 'harga_jual', 'harga_beli', 'kategori_id'], }, }, }, { type: 'function', function: { name: 'update_produk', description: 'Update/edit data produk yang sudah ada berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID produk yang ingin diupdate' }, nama: { type: 'string', description: 'Nama produk baru (opsional)' }, harga_jual: { type: 'number', description: 'Harga jual baru (opsional)' }, harga_beli: { type: 'number', description: 'Harga beli baru (opsional)' }, kategori_id: { type: 'number', description: 'ID kategori baru (opsional)' }, satuan: { type: 'string', description: 'Satuan baru (opsional)' }, stok_min: { type: 'number', description: 'Stok minimum baru (opsional)' }, is_aktif: { type: 'number', description: '1 = aktif, 0 = nonaktif (opsional)' }, }, required: ['id'], }, }, }, { type: 'function', function: { name: 'hapus_produk', description: 'Hapus (soft-delete) produk berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID produk yang ingin dihapus' }, }, required: ['id'], }, }, }, // ── Stok ── { type: 'function', function: { name: 'cek_stok', description: 'Cek stok produk tertentu berdasarkan nama atau ID produk.', parameters: { type: 'object', properties: { pencarian: { type: 'string', description: 'Nama atau kode produk untuk dicek stoknya' }, }, required: ['pencarian'], }, }, }, { type: 'function', function: { name: 'stok_rendah', description: 'Lihat daftar semua produk yang stoknya di bawah batas minimum (stok rendah atau habis).', parameters: { type: 'object', properties: {}, }, }, }, { type: 'function', function: { name: 'stok_kadaluarsa', description: 'Lihat daftar produk yang sudah kadaluarsa atau mendekati kadaluarsa (30 hari ke depan).', parameters: { type: 'object', properties: {}, }, }, }, { type: 'function', function: { name: 'tambah_stok_masuk', description: 'Tambahkan stok masuk (restock) untuk produk tertentu.', parameters: { type: 'object', properties: { produk_id: { type: 'number', description: 'ID produk yang mau ditambah stoknya (opsional jika nama_produk diisi)' }, nama_produk: { type: 'string', description: 'Nama produk yang mau ditambah stoknya (opsional jika produk_id diisi)' }, qty: { type: 'number', description: 'Jumlah stok yang masuk' }, harga_beli: { type: 'number', description: 'Harga beli per satuan' }, supplier_id: { type: 'number', description: 'ID supplier (opsional)' }, catatan: { type: 'string', description: 'Catatan restock (opsional)' }, }, required: ['qty', 'harga_beli'], }, }, }, // ── Transaksi ── { type: 'function', function: { name: 'ringkasan_penjualan', description: 'Lihat ringkasan penjualan (total transaksi dan revenue) berdasarkan periode waktu.', parameters: { type: 'object', properties: { periode: { type: 'string', enum: ['hari_ini', 'kemarin', 'minggu_ini', 'bulan_ini'], description: 'Periode waktu untuk ringkasan', }, }, required: ['periode'], }, }, }, { type: 'function', function: { name: 'detail_transaksi', description: 'Lihat detail transaksi berdasarkan nomor transaksi.', parameters: { type: 'object', properties: { no_transaksi: { type: 'string', description: 'Nomor transaksi (contoh: TKV-20260702-0001)' }, }, required: ['no_transaksi'], }, }, }, { type: 'function', function: { name: 'void_transaksi', description: 'Batalkan (void) transaksi penjualan tertentu berdasarkan nomor transaksi atau ID.', parameters: { type: 'object', properties: { no_transaksi: { type: 'string', description: 'Nomor transaksi yang ingin dibatalkan (opsional jika id diisi)' }, id: { type: 'number', description: 'ID transaksi yang ingin dibatalkan (opsional jika nomor transaksi diisi)' }, alasan: { type: 'string', description: 'Alasan pembatalan transaksi (wajib)' }, }, required: ['alasan'], }, }, }, // ── Member ── { type: 'function', function: { name: 'cari_member', description: 'Cari member/pelanggan berdasarkan nama, kode, atau nomor HP.', parameters: { type: 'object', properties: { pencarian: { type: 'string', description: 'Kata kunci pencarian (nama, kode, atau nomor HP)' }, }, required: ['pencarian'], }, }, }, { type: 'function', function: { name: 'tambah_member', description: 'Daftarkan member/pelanggan baru. Kode member di-generate otomatis.', parameters: { type: 'object', properties: { nama: { type: 'string', description: 'Nama member' }, nomor_hp: { type: 'string', description: 'Nomor HP member' }, alamat: { type: 'string', description: 'Alamat member (opsional)' }, limit_bon: { type: 'number', description: 'Limit bon/piutang (opsional, default: 0)' }, }, required: ['nama', 'nomor_hp'], }, }, }, { type: 'function', function: { name: 'update_member', description: 'Update data member/pelanggan berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID member yang ingin diupdate' }, nama: { type: 'string', description: 'Nama baru member (opsional)' }, nomor_hp: { type: 'string', description: 'Nomor HP baru (opsional)' }, alamat: { type: 'string', description: 'Alamat baru (opsional)' }, limit_bon: { type: 'number', description: 'Limit bon baru (opsional)' }, }, required: ['id'], }, }, }, { type: 'function', function: { name: 'hapus_member', description: 'Hapus data member/pelanggan berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID member yang ingin dihapus' }, }, required: ['id'], }, }, }, { type: 'function', function: { name: 'info_bon_member', description: 'Lihat informasi bon/piutang untuk member tertentu berdasarkan ID member.', parameters: { type: 'object', properties: { member_id: { type: 'number', description: 'ID member' }, }, required: ['member_id'], }, }, }, // ── Supplier ── { type: 'function', function: { name: 'cari_supplier', description: 'Cari supplier/pemasok berdasarkan nama.', parameters: { type: 'object', properties: { pencarian: { type: 'string', description: 'Nama supplier yang dicari' }, }, required: ['pencarian'], }, }, }, { type: 'function', function: { name: 'tambah_supplier', description: 'Tambahkan supplier/pemasok baru.', parameters: { type: 'object', properties: { nama: { type: 'string', description: 'Nama supplier' }, kontak: { type: 'string', description: 'Nama kontak person (opsional)' }, nomor_hp: { type: 'string', description: 'Nomor HP supplier (opsional)' }, alamat: { type: 'string', description: 'Alamat supplier (opsional)' }, }, required: ['nama'], }, }, }, { type: 'function', function: { name: 'update_supplier', description: 'Update data supplier/pemasok berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID supplier yang ingin diupdate' }, nama: { type: 'string', description: 'Nama baru supplier (opsional)' }, kontak: { type: 'string', description: 'Nama kontak person baru (opsional)' }, nomor_hp: { type: 'string', description: 'Nomor HP baru (opsional)' }, alamat: { type: 'string', description: 'Alamat baru (opsional)' }, }, required: ['id'], }, }, }, { type: 'function', function: { name: 'hapus_supplier', description: 'Hapus data supplier/pemasok berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID supplier yang ingin dihapus' }, }, required: ['id'], }, }, }, // ── Laporan ── { type: 'function', function: { name: 'produk_terlaris', description: 'Lihat daftar produk terlaris berdasarkan jumlah terjual dalam periode tertentu.', parameters: { type: 'object', properties: { limit: { type: 'number', description: 'Jumlah top produk yang ditampilkan (default: 10)' }, periode: { type: 'string', enum: ['minggu_ini', 'bulan_ini', 'semua'], description: 'Periode waktu (default: bulan_ini)', }, }, }, }, }, // ── Kategori ── { type: 'function', function: { name: 'daftar_kategori', description: 'Lihat semua kategori produk yang tersedia.', parameters: { type: 'object', properties: {}, }, }, }, { type: 'function', function: { name: 'tambah_kategori', description: 'Tambahkan kategori produk baru.', parameters: { type: 'object', properties: { nama: { type: 'string', description: 'Nama kategori baru' }, icon: { type: 'string', description: 'Icon/emoji untuk kategori (opsional)' }, }, required: ['nama'], }, }, }, { type: 'function', function: { name: 'update_kategori', description: 'Update nama atau icon kategori berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID kategori yang ingin di-update' }, nama: { type: 'string', description: 'Nama kategori baru (opsional)' }, icon: { type: 'string', description: 'Icon baru (opsional)' }, aktif: { type: 'number', description: '1 = aktif, 0 = nonaktif (opsional)' }, }, required: ['id'], }, }, }, { type: 'function', function: { name: 'hapus_kategori', description: 'Hapus kategori produk berdasarkan ID atau nama kategori.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID kategori yang ingin dihapus (opsional jika nama diisi)' }, nama: { type: 'string', description: 'Nama kategori yang ingin dihapus (opsional jika id diisi)' }, }, required: [], }, }, }, // ── Diskon & Promo ── { type: 'function', function: { name: 'daftar_diskon', description: 'Lihat daftar semua diskon, promosi, atau voucher toko.', parameters: { type: 'object', properties: {}, }, }, }, { type: 'function', function: { name: 'tambah_diskon', description: 'Buat promosi/diskon belanja baru.', parameters: { type: 'object', properties: { nama: { type: 'string', description: 'Nama promosi/diskon' }, tipe: { type: 'string', enum: ['total_persen', 'total_nominal', 'per_item_persen', 'per_item_nominal'], description: 'Tipe diskon', }, nilai: { type: 'number', description: 'Besar potongan (persentase/nominal Rupiah)' }, min_belanja: { type: 'number', description: 'Minimal belanja (opsional, default: 0)' }, tgl_mulai: { type: 'string', description: 'Tanggal mulai berlaku (format: YYYY-MM-DD)' }, tgl_selesai: { type: 'string', description: 'Tanggal berakhir promo (format: YYYY-MM-DD)' }, member_only: { type: 'number', enum: [0, 1], description: '1 = khusus member, 0 = umum. Default: 0' }, }, required: ['nama', 'tipe', 'nilai', 'tgl_mulai', 'tgl_selesai'], }, }, }, { type: 'function', function: { name: 'update_diskon', description: 'Update data diskon/promosi berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID diskon yang ingin diupdate' }, nama: { type: 'string', description: 'Nama promosi baru (opsional)' }, tipe: { type: 'string', enum: ['total_persen', 'total_nominal', 'per_item_persen', 'per_item_nominal'], description: 'Tipe diskon baru (opsional)' }, nilai: { type: 'number', description: 'Besar potongan baru (opsional)' }, min_belanja: { type: 'number', description: 'Minimal belanja baru (opsional)' }, tgl_mulai: { type: 'string', description: 'Tanggal mulai baru (opsional)' }, tgl_selesai: { type: 'string', description: 'Tanggal selesai baru (opsional)' }, is_aktif: { type: 'number', enum: [0, 1], description: '1 = aktif, 0 = nonaktif (opsional)' }, }, required: ['id'], }, }, }, { type: 'function', function: { name: 'hapus_diskon', description: 'Hapus data diskon/promosi dari database berdasarkan ID.', parameters: { type: 'object', properties: { id: { type: 'number', description: 'ID diskon yang ingin dihapus' }, }, required: ['id'], }, }, }, // ── Ekspor Laporan ── { type: 'function', function: { name: 'ekspor_laporan', description: 'Ekspor laporan keuangan/stok toko ke dalam format CSV, Excel, atau PDF.', parameters: { type: 'object', properties: { jenis: { type: 'string', enum: ['penjualan', 'laba-rugi', 'mutasi-stok'], description: 'Jenis laporan yang ingin diekspor', }, format: { type: 'string', enum: ['csv', 'excel', 'pdf'], description: 'Format output file (csv, excel, atau pdf)', }, tgl_mulai: { type: 'string', description: 'Tanggal mulai periode (format: YYYY-MM-DD, default: hari ini)' }, tgl_selesai: { type: 'string', description: 'Tanggal selesai periode (format: YYYY-MM-DD, default: hari ini)' }, }, required: ['jenis', 'format'], }, }, }, // ── Sistem ── { type: 'function', function: { name: 'statistik_toko', description: 'Lihat statistik/ringkasan lengkap kondisi toko saat ini (total produk, stok, transaksi hari ini, member, piutang, dll).', parameters: { type: 'object', properties: {}, }, }, }, ]; // ═══════════════════════════════════ // TOOL HANDLERS (eksekusi aksi) // ═══════════════════════════════════ const formatRupiah = (n: number) => 'Rp ' + n.toLocaleString('id-ID'); export const TOOL_HANDLERS: Record = { // ── Produk ── cari_produk: { nama: 'cari_produk', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const { products, total } = await ProdukService.getProduk(0, 10, undefined, undefined, params.pencarian); if (total === 0) return `Tidak ditemukan produk dengan pencarian "${params.pencarian}".`; const items = products.map((p: any) => { return `- [ID:${p.id}] ${p.nama} | Kode: ${p.kode} | Stok: ${Number(p.stok)} ${p.satuan} | Harga Jual: ${formatRupiah(Number(p.harga_jual))} | Kategori: ${p.kategori?.nama || '-'}`; }); return `Ditemukan ${total} produk:\n${items.join('\n')}`; }, }, detail_produk: { nama: 'detail_produk', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { try { const p: any = await ProdukService.getProdukById(params.id); return [ `Detail Produk [ID:${p.id}]:`, ` Nama: ${p.nama}`, ` Kode: ${p.kode}`, ` Barcode: ${p.barcode || '-'}`, ` Kategori: ${p.kategori?.nama || '-'}`, ` Supplier: ${p.supplier?.nama || '-'}`, ` Satuan: ${p.satuan}`, ` Harga Beli: ${formatRupiah(Number(p.harga_beli))}`, ` Harga Jual: ${formatRupiah(Number(p.harga_jual))}`, ` Margin: ${formatRupiah(Number(p.harga_jual) - Number(p.harga_beli))} (${(((Number(p.harga_jual) - Number(p.harga_beli)) / Number(p.harga_beli)) * 100).toFixed(1)}%)`, ` Stok: ${Number(p.stok)} ${p.satuan}`, ` Stok Minimum: ${Number(p.stok_min)}`, ` Status: ${p.is_aktif ? 'Aktif' : 'Nonaktif'}`, ` Expired: ${p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '-'}`, p.harga_tingkat && p.harga_tingkat.length > 0 ? ` Harga Tingkat:\n${p.harga_tingkat.map((ht: any) => ` - ${ht.tingkat}: min ${Number(ht.min_qty)} → ${formatRupiah(Number(ht.harga))}`).join('\n')}` : '', ].filter(Boolean).join('\n'); } catch (err: any) { return err.message || 'Produk tidak ditemukan.'; } }, }, tambah_produk: { nama: 'tambah_produk', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const kode = await generateKodeProduk(); const data = { kode, nama: params.nama, harga_jual: Number(params.harga_jual), harga_beli: Number(params.harga_beli), kategori_id: Number(params.kategori_id), satuan: params.satuan || 'pcs', barcode: params.barcode || null, stok_min: params.stok_min ? Number(params.stok_min) : 5, stok: params.stok ? Number(params.stok) : 0, }; try { const produk = await ProdukService.create(data, userId, ip, userAgent); return `Produk berhasil ditambahkan!\n ID: ${produk.id}\n Kode: ${produk.kode}\n Nama: ${produk.nama}\n Harga Jual: ${formatRupiah(Number(produk.harga_jual))}`; } catch (err: any) { return `Gagal menambah produk: ${err.message}`; } }, }, update_produk: { nama: 'update_produk', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const { id, ...data } = params; try { const cleanData: any = { ...data }; if (cleanData.harga_jual !== undefined) cleanData.harga_jual = Number(cleanData.harga_jual); if (cleanData.harga_beli !== undefined) cleanData.harga_beli = Number(cleanData.harga_beli); if (cleanData.kategori_id !== undefined) cleanData.kategori_id = Number(cleanData.kategori_id); if (cleanData.stok_min !== undefined) cleanData.stok_min = Number(cleanData.stok_min); if (cleanData.stok !== undefined) cleanData.stok = Number(cleanData.stok); const produk = await ProdukService.update(Number(id), cleanData, userId, ip, userAgent); return `Produk berhasil diupdate!\n ID: ${produk.id}\n Nama: ${produk.nama}\n Harga Jual: ${formatRupiah(Number(produk.harga_jual))}`; } catch (err: any) { return `Gagal mengupdate produk: ${err.message}`; } }, }, hapus_produk: { nama: 'hapus_produk', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { await ProdukService.delete(Number(params.id), userId, ip, userAgent); return `Produk dengan ID ${params.id} berhasil dihapus.`; } catch (err: any) { return `Gagal menghapus produk: ${err.message}`; } }, }, // ── Stok ── cek_stok: { nama: 'cek_stok', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const { products } = await ProdukService.getProduk(0, 5, undefined, undefined, params.pencarian); if (products.length === 0) return `Tidak ditemukan produk "${params.pencarian}".`; const items = products.map((p: any) => { const stok = Number(p.stok); const min = Number(p.stok_min); let status = '✅ Aman'; if (stok === 0) status = '🔴 Habis'; else if (stok <= min) status = '⚠️ Rendah'; return `- ${p.nama}: ${stok} ${p.satuan} (min: ${min}) — ${status}`; }); return `Stok produk:\n${items.join('\n')}`; }, }, stok_rendah: { nama: 'stok_rendah', butuhKonfirmasi: false, adminOnly: false, handler: async () => { const products = await prisma.$queryRaw` SELECT id, nama, stok, stok_min, satuan FROM produk WHERE deleted_at IS NULL AND is_aktif = 1 AND stok <= stok_min ORDER BY stok ASC LIMIT 20 `; if (products.length === 0) return 'Semua stok produk aman! Tidak ada yang rendah atau habis.'; const items = products.map((p: any) => { const stok = Number(p.stok); const icon = stok === 0 ? '🔴' : '⚠️'; return `${icon} [ID:${p.id}] ${p.nama}: ${stok} ${p.satuan} (min: ${Number(p.stok_min)})`; }); return `Produk stok rendah/habis (${products.length}):\n${items.join('\n')}`; }, }, stok_kadaluarsa: { nama: 'stok_kadaluarsa', butuhKonfirmasi: false, adminOnly: false, handler: async () => { const hariIni = new Date(); const batas = new Date(); batas.setDate(batas.getDate() + 30); const expired = await prisma.produk.findMany({ where: { deleted_at: null, is_aktif: 1, expired_date: { lt: hariIni } }, select: { id: true, nama: true, expired_date: true, stok: true }, take: 10, }); const nearExpired = await prisma.produk.findMany({ where: { deleted_at: null, is_aktif: 1, expired_date: { gte: hariIni, lte: batas } }, select: { id: true, nama: true, expired_date: true, stok: true }, take: 10, }); const lines: string[] = []; if (expired.length > 0) { lines.push('🔴 Sudah Kadaluarsa:'); expired.forEach((p) => { lines.push(` - [ID:${p.id}] ${p.nama} — Expired: ${p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '-'} | Stok: ${Number(p.stok)}`); }); } if (nearExpired.length > 0) { lines.push('⚠️ Mendekati Kadaluarsa (30 hari):'); nearExpired.forEach((p) => { lines.push(` - [ID:${p.id}] ${p.nama} — Expired: ${p.expired_date ? new Date(p.expired_date).toISOString().split('T')[0] : '-'} | Stok: ${Number(p.stok)}`); }); } if (lines.length === 0) return 'Tidak ada produk yang kadaluarsa atau mendekati kadaluarsa.'; return lines.join('\n'); }, }, tambah_stok_masuk: { nama: 'tambah_stok_masuk', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId) => { try { let produkId = params.produk_id ? Number(params.produk_id) : null; if (!produkId && params.nama_produk) { const prod = await prisma.produk.findFirst({ where: { nama: { contains: params.nama_produk }, deleted_at: null }, }); if (prod) { produkId = prod.id; } } if (!produkId) { return `Gagal menambah stok: Produk tidak ditemukan di database.`; } const result = await StokService.tambahStok({ produkId, qty: Number(params.qty), hargaBeli: Number(params.harga_beli), supplierId: params.supplier_id ? Number(params.supplier_id) : null, userId, catatan: params.catatan || 'Restock via chatbot', referensi: 'CHATBOT', }); return `Stok berhasil ditambahkan!\n Batch: ${result.batch.id}\n Qty: ${params.qty}\n Harga Beli: ${formatRupiah(params.harga_beli)}`; } catch (err: any) { return `Gagal menambah stok: ${err.message}`; } }, }, // ── Transaksi ── ringkasan_penjualan: { nama: 'ringkasan_penjualan', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const now = new Date(); let tanggalMulai: Date; let tanggalAkhir: Date = new Date(now); tanggalAkhir.setHours(23, 59, 59, 999); switch (params.periode) { case 'kemarin': { tanggalMulai = new Date(now); tanggalMulai.setDate(tanggalMulai.getDate() - 1); tanggalMulai.setHours(0, 0, 0, 0); tanggalAkhir = new Date(tanggalMulai); tanggalAkhir.setHours(23, 59, 59, 999); break; } case 'minggu_ini': { tanggalMulai = new Date(now); const day = tanggalMulai.getDay(); tanggalMulai.setDate(tanggalMulai.getDate() - (day === 0 ? 6 : day - 1)); tanggalMulai.setHours(0, 0, 0, 0); break; } case 'bulan_ini': { tanggalMulai = new Date(now.getFullYear(), now.getMonth(), 1); break; } default: { // hari_ini tanggalMulai = new Date(now); tanggalMulai.setHours(0, 0, 0, 0); break; } } const result = await prisma.transaksi.aggregate({ where: { tanggal: { gte: tanggalMulai, lte: tanggalAkhir }, deleted_at: null, status: { not: 'void' }, }, _count: { id: true }, _sum: { total: true, diskon_total: true }, }); const jumlah = result._count.id || 0; const total = Number(result._sum.total || 0); const diskon = Number(result._sum.diskon_total || 0); const periodeLabel: Record = { hari_ini: 'Hari Ini', kemarin: 'Kemarin', minggu_ini: 'Minggu Ini', bulan_ini: 'Bulan Ini', }; return [ `Ringkasan Penjualan — ${periodeLabel[params.periode] || params.periode}:`, ` Jumlah Transaksi: ${jumlah}`, ` Total Penjualan: ${formatRupiah(total)}`, ` Total Diskon: ${formatRupiah(diskon)}`, ` Rata-rata per Transaksi: ${jumlah > 0 ? formatRupiah(Math.round(total / jumlah)) : 'Rp 0'}`, ].join('\n'); }, }, detail_transaksi: { nama: 'detail_transaksi', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const tx = await prisma.transaksi.findUnique({ where: { no_transaksi: params.no_transaksi }, include: { transaksi_detail: { include: { produk: true } }, pembayaran: true, user: { select: { nama: true } }, member: { select: { nama: true, kode: true } }, }, }); if (!tx) return `Transaksi dengan nomor "${params.no_transaksi}" tidak ditemukan.`; const items = tx.transaksi_detail.map((d: any) => ` - ${d.nama_produk} x${Number(d.qty)} ${d.satuan} @ ${formatRupiah(Number(d.harga_satuan))} = ${formatRupiah(Number(d.subtotal))}` ); const bayar = tx.pembayaran.map((p: any) => ` - ${p.metode}: ${formatRupiah(Number(p.nominal))}` ); return [ `Detail Transaksi ${tx.no_transaksi}:`, ` Tanggal: ${tx.tanggal}`, ` Kasir: ${tx.user.nama}`, ` Member: ${tx.member ? `${tx.member.nama} (${tx.member.kode})` : '-'}`, ` Status: ${tx.status}`, ``, ` Items:`, ...items, ``, ` Subtotal: ${formatRupiah(Number(tx.subtotal))}`, ` Diskon: ${formatRupiah(Number(tx.diskon_total))}`, ` Total: ${formatRupiah(Number(tx.total))}`, ``, ` Pembayaran:`, ...bayar, ` Kembalian: ${formatRupiah(Number(tx.kembalian))}`, ].join('\n'); }, }, void_transaksi: { nama: 'void_transaksi', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { let txId = params.id ? Number(params.id) : null; if (!txId && params.no_transaksi) { const tx = await prisma.transaksi.findUnique({ where: { no_transaksi: params.no_transaksi } }); if (tx) txId = tx.id; } if (!txId) { return `Gagal membatalkan transaksi: Nomor atau ID transaksi tidak ditemukan.`; } await TransaksiService.prosesVoidTransaksi( txId, params.alasan || 'Pembatalan via chatbot', userId, ip, userAgent ); return `Transaksi dengan ID ${txId} (${params.no_transaksi || ''}) berhasil dibatalkan (void).`; } catch (err: any) { return `Gagal membatalkan transaksi: ${err.message}`; } }, }, // ── Member ── cari_member: { nama: 'cari_member', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const members = await MemberService.getMembers(0, 10, params.pencarian); if (members.length === 0) return `Tidak ditemukan member dengan pencarian "${params.pencarian}".`; const items = members.map((m: any) => `- [ID:${m.id}] ${m.nama} | Kode: ${m.kode} | HP: ${m.nomor_hp} | Poin: ${m.total_poin} | Bon: ${formatRupiah(Number(m.total_bon))}` ); return `Ditemukan ${members.length} member:\n${items.join('\n')}`; }, }, tambah_member: { nama: 'tambah_member', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const kode = await generateKodeMember(); try { const member = await MemberService.create({ kode, nama: params.nama, nomor_hp: params.nomor_hp, alamat: params.alamat || null, limit_bon: params.limit_bon ? Number(params.limit_bon) : 0, }, userId, ip, userAgent); return `Member berhasil didaftarkan!\n ID: ${member.id}\n Kode: ${member.kode}\n Nama: ${member.nama}\n HP: ${member.nomor_hp}`; } catch (err: any) { return `Gagal mendaftarkan member: ${err.message}`; } }, }, update_member: { nama: 'update_member', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const { id, ...data } = params; try { const cleanData: any = { ...data }; if (cleanData.limit_bon !== undefined) cleanData.limit_bon = Number(cleanData.limit_bon); const member = await MemberService.update(Number(id), cleanData, userId, ip, userAgent); return `Member berhasil diupdate!\n ID: ${member.id}\n Nama: ${member.nama}\n Limit Bon: ${formatRupiah(Number(member.limit_bon))}`; } catch (err: any) { return `Gagal mengupdate member: ${err.message}`; } }, }, hapus_member: { nama: 'hapus_member', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { await MemberService.delete(Number(params.id), userId, ip, userAgent); return `Member dengan ID ${params.id} berhasil dihapus.`; } catch (err: any) { return `Gagal menghapus member: ${err.message}`; } }, }, info_bon_member: { nama: 'info_bon_member', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const bonList = await BonService.getBon(0, 10, undefined, params.member_id); if (bonList.length === 0) return `Member dengan ID ${params.member_id} tidak memiliki bon/piutang.`; const member = (bonList[0] as any).member; const lines = [ `Info Bon/Piutang — ${member.nama} (${member.kode}):`, ` Total Bon Aktif: ${formatRupiah(Number(member.total_bon))}`, ` Limit Bon: ${formatRupiah(Number(member.limit_bon))}`, ` Sisa Limit: ${formatRupiah(Number(member.limit_bon) - Number(member.total_bon))}`, ``, ` Riwayat Bon:`, ]; bonList.forEach((b: any) => { lines.push(` - Bon #${b.id} | ${formatRupiah(Number(b.total_bon))} | Sisa: ${formatRupiah(Number(b.sisa_bon))} | Status: ${b.status} | Jatuh Tempo: ${b.jatuh_tempo ? new Date(b.jatuh_tempo).toISOString().split('T')[0] : '-'}`); }); return lines.join('\n'); }, }, // ── Supplier ── cari_supplier: { nama: 'cari_supplier', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const suppliers = await SupplierService.getSuppliers(0, 10, params.pencarian); if (suppliers.length === 0) return `Tidak ditemukan supplier dengan pencarian "${params.pencarian}".`; const items = suppliers.map((s: any) => `- [ID:${s.id}] ${s.nama} | HP: ${s.nomor_hp || '-'} | Kontak: ${s.kontak || '-'} | Hutang: ${formatRupiah(Number(s.hutang))}` ); return `Ditemukan ${suppliers.length} supplier:\n${items.join('\n')}`; }, }, tambah_supplier: { nama: 'tambah_supplier', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { const supplier = await SupplierService.create({ nama: params.nama, kontak: params.kontak || null, nomor_hp: params.nomor_hp || null, alamat: params.alamat || null, }, userId, ip, userAgent); return `Supplier berhasil ditambahkan!\n ID: ${supplier.id}\n Nama: ${supplier.nama}`; } catch (err: any) { return `Gagal menambah supplier: ${err.message}`; } }, }, update_supplier: { nama: 'update_supplier', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const { id, ...data } = params; try { const cleanData: any = { ...data }; const supplier = await SupplierService.update(Number(id), cleanData, userId, ip, userAgent); return `Supplier berhasil diupdate!\n ID: ${supplier.id}\n Nama: ${supplier.nama}`; } catch (err: any) { return `Gagal mengupdate supplier: ${err.message}`; } }, }, hapus_supplier: { nama: 'hapus_supplier', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { await SupplierService.delete(Number(params.id), userId, ip, userAgent); return `Supplier dengan ID ${params.id} berhasil dihapus.`; } catch (err: any) { return `Gagal menghapus supplier: ${err.message}`; } }, }, // ── Laporan ── produk_terlaris: { nama: 'produk_terlaris', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { const limit = params.limit || 10; const now = new Date(); let dateFilter = ''; if (params.periode === 'minggu_ini') { const start = new Date(now); const day = start.getDay(); start.setDate(start.getDate() - (day === 0 ? 6 : day - 1)); dateFilter = `AND t.tanggal >= '${start.toISOString().split('T')[0]}'`; } else if (params.periode !== 'semua') { // bulan_ini (default) const start = new Date(now.getFullYear(), now.getMonth(), 1); dateFilter = `AND t.tanggal >= '${start.toISOString().split('T')[0]}'`; } const results = await prisma.$queryRawUnsafe(` SELECT td.nama_produk, SUM(td.qty) as total_qty, SUM(td.subtotal) as total_revenue FROM transaksi_detail td JOIN transaksi t ON t.id = td.transaksi_id WHERE t.deleted_at IS NULL AND t.status != 'void' ${dateFilter} GROUP BY td.produk_id, td.nama_produk ORDER BY total_qty DESC LIMIT ${limit} `); if (results.length === 0) return 'Belum ada data penjualan untuk periode ini.'; const periodeLabel: Record = { minggu_ini: 'Minggu Ini', bulan_ini: 'Bulan Ini', semua: 'Semua Waktu', }; const lines = [`Produk Terlaris — ${periodeLabel[params.periode || 'bulan_ini'] || 'Bulan Ini'}:`]; results.forEach((r: any, i: number) => { lines.push(` ${i + 1}. ${r.nama_produk} — ${Number(r.total_qty)} terjual | Revenue: ${formatRupiah(Number(r.total_revenue))}`); }); return lines.join('\n'); }, }, // ── Kategori ── daftar_kategori: { nama: 'daftar_kategori', butuhKonfirmasi: false, adminOnly: false, handler: async () => { const categories = await KategoriService.getKategori(0, 100); if (categories.length === 0) return 'Belum ada kategori produk.'; const items = categories.map((k: any) => `- [ID:${k.id}] ${k.nama} ${k.icon ? `(${k.icon})` : ''}` ); return `Daftar Kategori (${categories.length}):\n${items.join('\n')}`; }, }, // ── Sistem ── statistik_toko: { nama: 'statistik_toko', butuhKonfirmasi: false, adminOnly: false, handler: async () => { const hariIni = new Date(); hariIni.setHours(0, 0, 0, 0); const besok = new Date(hariIni); besok.setDate(besok.getDate() + 1); const [totalProduk, totalMember, totalSupplier, totalKategori, txHariIni, bonAktif, shiftAktif, stokRendah] = await Promise.all([ prisma.produk.count({ where: { deleted_at: null, is_aktif: 1 } }), prisma.member.count({ where: { deleted_at: null, aktif: 1 } }), prisma.supplier.count({ where: { deleted_at: null, aktif: 1 } }), prisma.kategori.count({ where: { deleted_at: null, aktif: 1 } }), prisma.transaksi.aggregate({ where: { tanggal: { gte: hariIni, lt: besok }, deleted_at: null, status: { not: 'void' } }, _count: { id: true }, _sum: { total: true }, }), prisma.bon.aggregate({ where: { status: 'aktif' }, _sum: { sisa_bon: true }, _count: { id: true } }), prisma.shift.findFirst({ where: { status: 'buka' }, include: { user: { select: { nama: true } } } }), prisma.$queryRaw`SELECT COUNT(*) as count FROM produk WHERE deleted_at IS NULL AND is_aktif = 1 AND stok <= stok_min`, ]); return [ '📊 Statistik Toko Tokiva:', '', `📦 Produk Aktif: ${totalProduk}`, `📂 Kategori: ${totalKategori}`, `👥 Member: ${totalMember}`, `🚚 Supplier: ${totalSupplier}`, '', `💰 Transaksi Hari Ini: ${txHariIni._count.id || 0} transaksi`, `💵 Revenue Hari Ini: ${formatRupiah(Number(txHariIni._sum.total || 0))}`, '', `📋 Bon Aktif: ${bonAktif._count.id || 0} bon`, `💳 Total Piutang: ${formatRupiah(Number(bonAktif._sum.sisa_bon || 0))}`, '', `⚠️ Produk Stok Rendah/Habis: ${Number((stokRendah as any)[0]?.count || 0)}`, `🕐 Shift Aktif: ${shiftAktif ? `${shiftAktif.user.nama}` : 'Tidak ada'}`, ].join('\n'); }, }, tambah_kategori: { nama: 'tambah_kategori', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { const result = await KategoriService.create(params, userId, ip, userAgent); return `Kategori "${result.nama}" berhasil dibuat dengan ID ${result.id}.`; } catch (err: any) { return `Gagal menambah kategori: ${err.message}`; } }, }, update_kategori: { nama: 'update_kategori', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const { id, ...data } = params; try { const result = await KategoriService.update(Number(id), data, userId, ip, userAgent); return `Kategori ID ${id} berhasil diperbarui (Nama: ${result.nama}).`; } catch (err: any) { return `Gagal memperbarui kategori: ${err.message}`; } }, }, hapus_kategori: { nama: 'hapus_kategori', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { let idVal = params.id ? Number(params.id) : null; if (!idVal && params.nama) { const cat = await prisma.kategori.findFirst({ where: { nama: params.nama, deleted_at: null }, }); if (cat) { idVal = cat.id; } } if (!idVal) { return `Gagal menghapus kategori: ID atau nama kategori tidak valid atau tidak ditemukan di database.`; } await KategoriService.delete(idVal, userId, ip, userAgent); return `Kategori "${params.nama || idVal}" berhasil dihapus.`; } catch (err: any) { return `Gagal menghapus kategori: ${err.message}`; } }, }, daftar_diskon: { nama: 'daftar_diskon', butuhKonfirmasi: false, adminOnly: false, handler: async () => { const promos = await prisma.diskon.findMany({ orderBy: { created_at: 'desc' }, }); if (promos.length === 0) return 'Belum ada promosi atau diskon belanja yang terdaftar.'; const formatTipe = (t: string) => { const map: Record = { total_persen: 'Potongan Belanja (%)', total_nominal: 'Potongan Belanja (Rupiah)', per_item_persen: 'Potongan Produk (%)', per_item_nominal: 'Potongan Produk (Rupiah)', }; return map[t] || t; }; const lines = promos.map((p) => { const status = p.is_aktif ? '✅ Aktif' : '❌ Nonaktif'; const tglMulaiStr = p.tgl_mulai ? new Date(p.tgl_mulai).toISOString().split('T')[0] : ''; const tglSelesaiStr = p.tgl_selesai ? new Date(p.tgl_selesai).toISOString().split('T')[0] : ''; const nilaiStr = p.tipe.includes('persen') ? `${p.nilai}%` : formatRupiah(Number(p.nilai)); return `- [ID:${p.id}] ${p.nama} | Tipe: ${formatTipe(p.tipe)} | Nilai: ${nilaiStr} | Min Belanja: ${formatRupiah(Number(p.min_belanja))} | Berlaku: ${tglMulaiStr} s/d ${tglSelesaiStr} | Status: ${status}`; }); return `Daftar Promosi/Diskon Toko:\n${lines.join('\n')}`; }, }, tambah_diskon: { nama: 'tambah_diskon', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { try { const promo = await prisma.diskon.create({ data: { nama: params.nama, tipe: params.tipe, nilai: Number(params.nilai), min_belanja: params.min_belanja !== undefined ? Number(params.min_belanja) : 0, tgl_mulai: new Date(params.tgl_mulai), tgl_selesai: new Date(params.tgl_selesai), member_only: params.member_only !== undefined ? Number(params.member_only) : 0, is_aktif: 1, }, }); await catatAudit({ userId, aksi: 'create', entitas: 'diskon', entitasId: promo.id, dataBaru: { nama: promo.nama, tipe: promo.tipe, nilai: Number(promo.nilai) }, ipAddress: ip, userAgent, }); return `Diskon/Promosi "${promo.nama}" berhasil dibuat dengan ID ${promo.id}.`; } catch (err: any) { return `Gagal membuat promosi: ${err.message}`; } }, }, update_diskon: { nama: 'update_diskon', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const { id, ...data } = params; const idNum = Number(id); try { const existing = await prisma.diskon.findUnique({ where: { id: idNum } }); if (!existing) return `Diskon dengan ID ${id} tidak ditemukan.`; const updateData: any = {}; if (data.nama !== undefined) updateData.nama = data.nama; if (data.tipe !== undefined) updateData.tipe = data.tipe; if (data.nilai !== undefined) updateData.nilai = Number(data.nilai); if (data.min_belanja !== undefined) updateData.min_belanja = Number(data.min_belanja); if (data.tgl_mulai !== undefined) updateData.tgl_mulai = new Date(data.tgl_mulai); if (data.tgl_selesai !== undefined) updateData.tgl_selesai = new Date(data.tgl_selesai); if (data.is_aktif !== undefined) updateData.is_aktif = Number(data.is_aktif); const promo = await prisma.diskon.update({ where: { id: idNum }, data: updateData, }); await catatAudit({ userId, aksi: 'update', entitas: 'diskon', entitasId: promo.id, dataLama: { nama: existing.nama, tipe: existing.tipe, nilai: Number(existing.nilai) }, dataBaru: { nama: promo.nama, tipe: promo.tipe, nilai: Number(promo.nilai) }, ipAddress: ip, userAgent, }); return `Diskon ID ${id} berhasil diperbarui (Nama: ${promo.nama}).`; } catch (err: any) { return `Gagal memperbarui diskon: ${err.message}`; } }, }, hapus_diskon: { nama: 'hapus_diskon', butuhKonfirmasi: true, adminOnly: true, handler: async (params, userId, ip, userAgent) => { const idNum = Number(params.id); try { const existing = await prisma.diskon.findUnique({ where: { id: idNum } }); if (!existing) return `Diskon dengan ID ${params.id} tidak ditemukan.`; await prisma.diskon.delete({ where: { id: idNum } }); await catatAudit({ userId, aksi: 'delete', entitas: 'diskon', entitasId: idNum, dataLama: { nama: existing.nama, tipe: existing.tipe, nilai: Number(existing.nilai) }, dataBaru: { deleted: true }, ipAddress: ip, userAgent, }); return `Diskon dengan ID ${params.id} berhasil dihapus dari database.`; } catch (err: any) { return `Gagal menghapus diskon: ${err.message}`; } }, }, ekspor_laporan: { nama: 'ekspor_laporan', butuhKonfirmasi: false, adminOnly: false, handler: async (params) => { try { const tglMulai = params.tgl_mulai ? new Date(params.tgl_mulai) : new Date(); const tglSelesai = params.tgl_selesai ? new Date(params.tgl_selesai) : new Date(); if (!params.tgl_mulai) tglMulai.setHours(0,0,0,0); if (!params.tgl_selesai) tglSelesai.setHours(23,59,59,999); const result = await OpsService.eksporLaporan({ jenis: params.jenis, format: params.format, tglMulai, tglSelesai, }); return `Laporan ${params.jenis} (${params.format.toUpperCase()}) berhasil dibuat!\nSilakan download melalui tautan berikut:\n👉 ${result.url}`; } catch (err: any) { return `Gagal mengekspor laporan: ${err.message}`; } }, }, }; /** * Ambil tools yang boleh diakses berdasarkan role user. */ export function getToolsForRole(role: string): ChatCompletionTool[] { if (role === 'admin') return TOOL_DEFINITIONS; // Kasir hanya bisa akses tools non-admin const adminOnlyTools = Object.values(TOOL_HANDLERS) .filter((h) => h.adminOnly) .map((h) => h.nama); return TOOL_DEFINITIONS.filter( (t) => !adminOnlyTools.includes((t as any).function.name) ); }