Spaces:
Sleeping
Sleeping
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee | /** | |
| * 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<string>; | |
| } | |
| // ═══════════════════════════════════ | |
| // 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<string, ToolHandler> = { | |
| // ── 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<any[]>` | |
| 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<string, string> = { | |
| 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<any[]>(` | |
| 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<string, string> = { | |
| 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<any[]>`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<string, string> = { | |
| 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) | |
| ); | |
| } | |