Tokiva-backend-Node / src /utils /chatbot-tools.ts
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee
Raw
History Blame Contribute Delete
51.2 kB
/**
* 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)
);
}