Spaces:
Sleeping
Sleeping
anoderb
feat: implement chatbot service, active shift logic corrections, audit logs, and void transaction tool
48013ee | import prisma from '../utils/db'; | |
| import { catatAudit } from '../utils/audit'; | |
| import { generateKodeShift } from '../utils/kodeGenerator'; | |
| import { APIError } from '../utils/errors'; | |
| import fs from 'fs'; | |
| import path from 'path'; | |
| import PDFDocument from 'pdfkit'; | |
| import { uploadBufferToSupabase } from '../utils/supabase'; | |
| export class OpsService { | |
| static async getActiveShift(userId: number) { | |
| return await prisma.shift.findFirst({ | |
| where: { | |
| user_id: userId, | |
| status: 'buka', | |
| }, | |
| }); | |
| } | |
| static async bukaShift(modalAwal: number, catatan: string | null, actorId: number, ip: string, userAgent: string) { | |
| const existing = await this.getActiveShift(actorId); | |
| if (existing) { | |
| throw new APIError(`Anda sudah memiliki shift yang aktif (${existing.kode}). Tutup terlebih dahulu.`, 400); | |
| } | |
| const kode = await generateKodeShift(); | |
| const shift = await prisma.shift.create({ | |
| data: { | |
| user_id: actorId, | |
| kode: kode, | |
| modal_awal: modalAwal, | |
| waktu_buka: new Date(), | |
| status: 'buka', | |
| catatan: catatan, | |
| }, | |
| }); | |
| await catatAudit({ | |
| userId: actorId, | |
| aksi: 'update', | |
| entitas: 'shift', | |
| entitasId: shift.id, | |
| dataBaru: { kode, modal_awal: modalAwal }, | |
| ipAddress: ip, | |
| userAgent, | |
| }); | |
| return shift; | |
| } | |
| static async tutupShift(modalAkhirFisik: number, catatan: string | null, actorId: number, ip: string, userAgent: string) { | |
| const shift = await this.getActiveShift(actorId); | |
| if (!shift) { | |
| throw new APIError('Tidak ada shift aktif yang dapat ditutup.', 400); | |
| } | |
| const saldoSistem = Number(shift.modal_awal) + Number(shift.total_tunai); | |
| const selisih = modalAkhirFisik - saldoSistem; | |
| let newCatatan = shift.catatan || ''; | |
| if (catatan) { | |
| newCatatan = `${newCatatan} | Penutupan: ${catatan}`.trim().replace(/^ \| /, ''); | |
| } | |
| const updated = await prisma.shift.update({ | |
| where: { id: shift.id }, | |
| data: { | |
| total_selisih: selisih, | |
| waktu_tutup: new Date(), | |
| status: 'tutup', | |
| catatan: newCatatan, | |
| }, | |
| }); | |
| // Buat notifikasi | |
| const selisihRupiah = Math.floor(selisih).toLocaleString('id-ID').replace(/,/g, '.'); | |
| const pesanNotif = `Kasir (User ID ${actorId}) telah menutup shift ${shift.kode}. Modal Awal: Rp ${Number(shift.modal_awal).toLocaleString('id-ID')}. Selisih Kas Fisik vs Sistem: Rp ${selisihRupiah}.`; | |
| await prisma.notifikasi.create({ | |
| data: { | |
| tipe: 'shift_tutup', | |
| judul: `Shift Ditutup: ${shift.kode}`, | |
| pesan: pesanNotif, | |
| referensi_id: shift.id, | |
| }, | |
| }); | |
| await catatAudit({ | |
| userId: actorId, | |
| aksi: 'update', | |
| entitas: 'shift', | |
| entitasId: shift.id, | |
| dataLama: { status: 'buka' }, | |
| dataBaru: { status: 'tutup', total_selisih: selisih, fisik: modalAkhirFisik }, | |
| ipAddress: ip, | |
| userAgent, | |
| }); | |
| return updated; | |
| } | |
| // Settings | |
| static async getSettings() { | |
| return await prisma.pengaturan.findMany(); | |
| } | |
| static async getSettingByKey(key: string) { | |
| const setting = await prisma.pengaturan.findUnique({ | |
| where: { kunci: key }, | |
| }); | |
| if (!setting) { | |
| throw new APIError(`Pengaturan '${key}' tidak ditemukan`, 404); | |
| } | |
| return setting.nilai; | |
| } | |
| static async updateSetting(key: string, data: any, actorId: number, ip: string, userAgent: string) { | |
| const existing = await prisma.pengaturan.findUnique({ | |
| where: { kunci: key }, | |
| }); | |
| const dataLama = existing ? { nilai: existing.nilai } : null; | |
| const setting = await prisma.pengaturan.upsert({ | |
| where: { kunci: key }, | |
| create: { | |
| kunci: key, | |
| nilai: data.nilai, | |
| deskripsi: data.deskripsi ?? null, | |
| }, | |
| update: { | |
| nilai: data.nilai, | |
| deskripsi: data.deskripsi !== undefined ? data.deskripsi : undefined, | |
| }, | |
| }); | |
| await catatAudit({ | |
| userId: actorId, | |
| aksi: 'update', | |
| entitas: 'pengaturan', | |
| entitasId: setting.id, | |
| dataLama, | |
| dataBaru: { nilai: data.nilai }, | |
| ipAddress: ip, | |
| userAgent, | |
| }); | |
| return setting; | |
| } | |
| // Notifikasi | |
| static async getNotifications(skip = 0, limit = 20, isDibaca?: number) { | |
| const where: any = {}; | |
| if (isDibaca !== undefined) { | |
| where.is_dibaca = isDibaca; | |
| } | |
| return await prisma.notifikasi.findMany({ | |
| where, | |
| orderBy: { created_at: 'desc' }, | |
| skip, | |
| take: limit, | |
| }); | |
| } | |
| static async markAllNotifAsRead() { | |
| const result = await prisma.notifikasi.updateMany({ | |
| where: { is_dibaca: 0 }, | |
| data: { is_dibaca: 1 }, | |
| }); | |
| return result.count; | |
| } | |
| static async markNotifAsRead(id: number) { | |
| const notif = await prisma.notifikasi.findUnique({ where: { id } }); | |
| if (!notif) { | |
| throw new APIError('Notifikasi tidak ditemukan', 404); | |
| } | |
| return await prisma.notifikasi.update({ | |
| where: { id }, | |
| data: { is_dibaca: 1 }, | |
| }); | |
| } | |
| static async getUnreadNotifCount() { | |
| return await prisma.notifikasi.count({ | |
| where: { is_dibaca: 0 }, | |
| }); | |
| } | |
| // Dashboard Reports | |
| static async getDashboardSummary() { | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); | |
| const tomorrow = new Date(today); | |
| tomorrow.setDate(tomorrow.getDate() + 1); | |
| // 1. Total Penjualan Hari Ini | |
| const sumResult = await prisma.transaksi.aggregate({ | |
| where: { | |
| tanggal: { gte: today, lt: tomorrow }, | |
| status: { not: 'void' }, | |
| }, | |
| _sum: { total: true }, | |
| }); | |
| const penjualanHariIni = Number(sumResult._sum.total ?? 0); | |
| // 2. Total Transaksi Hari Ini | |
| const jumlahTransaksiHariIni = await prisma.transaksi.count({ | |
| where: { | |
| tanggal: { gte: today, lt: tomorrow }, | |
| status: { not: 'void' }, | |
| }, | |
| }); | |
| // 3. Total Produk Terdaftar | |
| const totalProduk = await prisma.produk.count({ | |
| where: { deleted_at: null }, | |
| }); | |
| // 4. Produk Stok Rendah | |
| const products = await prisma.produk.findMany({ | |
| where: { deleted_at: null }, | |
| }); | |
| const stokRendah = products.filter((p) => Number(p.stok) <= Number(p.stok_min)).length; | |
| // 5. Total Piutang | |
| const sumPiutang = await prisma.member.aggregate({ | |
| where: { aktif: 1 }, | |
| _sum: { total_bon: true }, | |
| }); | |
| const totalPiutang = Number(sumPiutang._sum.total_bon ?? 0); | |
| // 6. Grafik penjualan 7 hari terakhir | |
| const grafik7Hari: any[] = []; | |
| const todayForGraph = new Date(); | |
| todayForGraph.setHours(0, 0, 0, 0); | |
| for (let i = 6; i >= 0; i--) { | |
| const targetDay = new Date(todayForGraph); | |
| targetDay.setDate(targetDay.getDate() - i); | |
| const nextDay = new Date(targetDay); | |
| nextDay.setDate(nextDay.getDate() + 1); | |
| const daySum = await prisma.transaksi.aggregate({ | |
| where: { | |
| tanggal: { gte: targetDay, lt: nextDay }, | |
| status: { not: 'void' }, | |
| }, | |
| _sum: { total: true }, | |
| }); | |
| const dayStr = `${String(targetDay.getDate()).padStart(2, '0')}/${String(targetDay.getMonth() + 1).padStart(2, '0')}`; | |
| grafik7Hari.push({ | |
| tanggal: dayStr, | |
| nominal: Number(daySum._sum.total ?? 0), | |
| }); | |
| } | |
| // 7. Top 5 Produk Terlaris (7 hari terakhir) | |
| const tujuhHariLalu = new Date(); | |
| tujuhHariLalu.setDate(tujuhHariLalu.getDate() - 7); | |
| tujuhHariLalu.setHours(0, 0, 0, 0); | |
| const terlarisRaw = await prisma.$queryRaw<any[]>` | |
| SELECT td.nama_produk as nama, SUM(td.qty) as terjual | |
| FROM transaksi_detail td | |
| JOIN transaksi t ON t.id = td.transaksi_id | |
| WHERE t.deleted_at IS NULL AND t.status != 'void' AND t.tanggal >= ${tujuhHariLalu} | |
| GROUP BY td.produk_id, td.nama_produk | |
| ORDER BY terjual DESC | |
| LIMIT 5 | |
| `; | |
| const produkTerlaris = terlarisRaw.map((p) => ({ | |
| nama: p.nama, | |
| terjual: Number(p.terjual), | |
| })); | |
| // 8. Bon Jatuh Tempo Aktif | |
| const bonJatuhTempoRaw = await prisma.bon.findMany({ | |
| where: { status: 'aktif' }, | |
| include: { member: { select: { nama: true } } }, | |
| orderBy: { jatuh_tempo: 'asc' }, | |
| take: 5, | |
| }); | |
| const bonJatuhTempo = bonJatuhTempoRaw.map((b) => ({ | |
| nama: b.member.nama, | |
| nominal: Number(b.sisa_bon), | |
| jatuhTempo: b.jatuh_tempo ? b.jatuh_tempo.toISOString().split('T')[0] : '', | |
| status: b.status, | |
| })); | |
| return { | |
| penjualan_hari_ini: penjualanHariIni, | |
| jumlah_transaksi_hari_ini: jumlahTransaksiHariIni, | |
| total_produk: totalProduk, | |
| stok_rendah: stokRendah, | |
| total_piutang: totalPiutang, | |
| grafik_penjualan_7_hari: grafik7Hari, | |
| produk_terlaris: produkTerlaris, | |
| bon_jatuh_tempo: bonJatuhTempo, | |
| }; | |
| } | |
| static async buatLaporanPenjualan(tglMulai: Date, tglSelesai: Date) { | |
| const endOfDay = new Date(tglSelesai); | |
| endOfDay.setHours(23, 59, 59, 999); | |
| const transaksiList = await prisma.transaksi.findMany({ | |
| where: { | |
| tanggal: { gte: tglMulai, lte: endOfDay }, | |
| status: { not: 'void' }, | |
| }, | |
| include: { | |
| pembayaran: true, | |
| }, | |
| }); | |
| let totalOmset = 0; | |
| let totalDiskon = 0; | |
| const metodePembayaranSummary: Record<string, number> = { | |
| tunai: 0, | |
| qris: 0, | |
| transfer: 0, | |
| bon: 0, | |
| voucher: 0, | |
| }; | |
| for (const t of transaksiList) { | |
| totalOmset += Number(t.total); | |
| totalDiskon += Number(t.diskon_total); | |
| for (const p of t.pembayaran) { | |
| if (metodePembayaranSummary[p.metode] !== undefined) { | |
| metodePembayaranSummary[p.metode] += Number(p.nominal); | |
| } | |
| } | |
| } | |
| const kontribusiPersen: Record<string, number> = {}; | |
| for (const k in metodePembayaranSummary) { | |
| if (totalOmset > 0) { | |
| kontribusiPersen[k] = Number(((metodePembayaranSummary[k] / totalOmset) * 100).toFixed(2)); | |
| } else { | |
| kontribusiPersen[k] = 0; | |
| } | |
| } | |
| return { | |
| periode: { | |
| mulai: tglMulai.toISOString().slice(0, 10), | |
| selesai: tglSelesai.toISOString().slice(0, 10), | |
| }, | |
| total_transaksi: transaksiList.length, | |
| total_omset: totalOmset, | |
| total_diskon: totalDiskon, | |
| metode_pembayaran: metodePembayaranSummary, | |
| kontribusi_pembayaran_persen: kontribusiPersen, | |
| }; | |
| } | |
| static async buatLaporanLabaRugi(tglMulai: Date, tglSelesai: Date) { | |
| const endOfDay = new Date(tglSelesai); | |
| endOfDay.setHours(23, 59, 59, 999); | |
| const sumResult = await prisma.transaksi.aggregate({ | |
| where: { | |
| tanggal: { gte: tglMulai, lte: endOfDay }, | |
| status: { not: 'void' }, | |
| }, | |
| _sum: { total: true }, | |
| }); | |
| const omset = Number(sumResult._sum.total ?? 0); | |
| const detailList = await prisma.transaksi_detail.findMany({ | |
| where: { | |
| transaksi: { | |
| tanggal: { gte: tglMulai, lte: endOfDay }, | |
| status: { not: 'void' }, | |
| }, | |
| }, | |
| include: { | |
| produk: true, | |
| }, | |
| }); | |
| const hpp = detailList.reduce((acc, detail) => { | |
| const hargaBeli = Number(detail.produk?.harga_beli ?? detail.harga_satuan); | |
| return acc + Number(detail.qty) * hargaBeli; | |
| }, 0); | |
| const labaKotor = omset - hpp; | |
| const marginLaba = omset > 0 ? Number(((labaKotor / omset) * 100).toFixed(2)) : 0; | |
| return { | |
| periode: { | |
| mulai: tglMulai.toISOString().slice(0, 10), | |
| selesai: tglSelesai.toISOString().slice(0, 10), | |
| }, | |
| pendapatan_omset: omset, | |
| harga_pokok_penjualan: hpp, | |
| laba_kotor: labaKotor, | |
| margin_laba_persen: marginLaba, | |
| }; | |
| } | |
| static async eksporLaporan(params: { | |
| jenis: 'penjualan' | 'laba-rugi' | 'mutasi-stok'; | |
| format: 'csv' | 'excel' | 'pdf'; | |
| tglMulai: Date; | |
| tglSelesai: Date; | |
| }): Promise<{ filename: string; url: string }> { | |
| const { jenis, format, tglMulai, tglSelesai } = params; | |
| const endOfDay = new Date(tglSelesai); | |
| endOfDay.setHours(23, 59, 59, 999); | |
| const timestamp = Date.now(); | |
| const ext = format === 'pdf' ? 'pdf' : 'csv'; | |
| const filename = `laporan_${jenis}_${timestamp}.${ext}`; | |
| const filePath = path.join(__dirname, '../public/exports', filename); | |
| // Pastikan folder exports ada sebelum menulis berkas | |
| const exportDir = path.dirname(filePath); | |
| if (!fs.existsSync(exportDir)) { | |
| fs.mkdirSync(exportDir, { recursive: true }); | |
| } | |
| if (ext === 'csv') { | |
| let content = ''; | |
| if (jenis === 'penjualan') { | |
| const data = await prisma.transaksi.findMany({ | |
| where: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null }, | |
| include: { pembayaran: true, user: { select: { nama: true } } }, | |
| orderBy: { tanggal: 'asc' }, | |
| }); | |
| content = 'No. Invoice;Tanggal;Waktu;Kasir;Total;Diskon;Kembalian;Status;Metode Pembayaran\n'; | |
| data.forEach((tx) => { | |
| const metode = tx.pembayaran.map((p) => p.metode).join(', '); | |
| content += `${tx.no_transaksi};${new Date(tx.tanggal).toISOString().split('T')[0]};${tx.waktu || ''};${tx.user?.nama || ''};${Number(tx.total)};${Number(tx.diskon_total)};${Number(tx.kembalian)};${tx.status};${metode}\n`; | |
| }); | |
| } else if (jenis === 'laba-rugi') { | |
| const details = await prisma.transaksi_detail.findMany({ | |
| where: { transaksi: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null, status: { not: 'void' } } }, | |
| include: { produk: true, transaksi: true }, | |
| }); | |
| content = 'No. Invoice;Tanggal;Produk;Qty;Harga Satuan;Subtotal;Harga Beli (HPP);Laba Kotor\n'; | |
| details.forEach((d) => { | |
| const hpp = Number(d.produk?.harga_beli ?? d.harga_satuan); | |
| const laba = Number(d.subtotal) - (hpp * Number(d.qty)); | |
| content += `${d.transaksi.no_transaksi};${new Date(d.transaksi.tanggal).toISOString().split('T')[0]};${d.nama_produk};${Number(d.qty)};${Number(d.harga_satuan)};${Number(d.subtotal)};${hpp * Number(d.qty)};${laba}\n`; | |
| }); | |
| } else if (jenis === 'mutasi-stok') { | |
| const logs = await prisma.stok_log.findMany({ | |
| where: { created_at: { gte: tglMulai, lte: endOfDay } }, | |
| include: { produk: true, user: { select: { nama: true } } }, | |
| orderBy: { created_at: 'desc' }, | |
| }); | |
| content = 'Tanggal;Produk;Tipe Mutasi;Qty;Stok Sebelum;Stok Sesudah;Kasir;Catatan\n'; | |
| logs.forEach((log) => { | |
| content += `${new Date(log.created_at).toISOString().replace('T', ' ').substring(0, 19)};${log.produk.nama};${log.tipe};${Number(log.qty)};${Number(log.stok_sebelum)};${Number(log.stok_sesudah)};${log.user?.nama || ''};${log.catatan || ''}\n`; | |
| }); | |
| } | |
| fs.writeFileSync(filePath, content, 'utf-8'); | |
| } else { | |
| // PDF format menggunakan pdfkit | |
| const doc = new PDFDocument({ margin: 30 }); | |
| const stream = fs.createWriteStream(filePath); | |
| doc.pipe(stream); | |
| // Header PDF | |
| doc.fontSize(18).text('TOKIVA POINT OF SALE', { align: 'center' }); | |
| doc.fontSize(12).text(`Laporan ${jenis.toUpperCase().replace('-', ' ')}`, { align: 'center' }); | |
| doc.fontSize(9).text(`Periode: ${tglMulai.toISOString().split('T')[0]} s/d ${tglSelesai.toISOString().split('T')[0]}`, { align: 'center' }); | |
| doc.moveDown(1.5); | |
| if (jenis === 'penjualan') { | |
| const data = await prisma.transaksi.findMany({ | |
| where: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null }, | |
| include: { user: { select: { nama: true } } }, | |
| orderBy: { tanggal: 'asc' }, | |
| }); | |
| doc.fontSize(10).text('Daftar Transaksi:', { underline: true }); | |
| doc.moveDown(0.5); | |
| let totalOmset = 0; | |
| data.forEach((tx, idx) => { | |
| totalOmset += Number(tx.total); | |
| doc.fontSize(9).text(`${idx + 1}. Invoice: ${tx.no_transaksi} | Tanggal: ${new Date(tx.tanggal).toISOString().split('T')[0]} | Kasir: ${tx.user?.nama || '-'} | Total: Rp ${Number(tx.total).toLocaleString('id-ID')} (${tx.status})`); | |
| }); | |
| doc.moveDown(1); | |
| doc.font('Helvetica-Bold').fontSize(11).text(`TOTAL OMZET: Rp ${totalOmset.toLocaleString('id-ID')}`); | |
| doc.font('Helvetica'); | |
| } else if (jenis === 'laba-rugi') { | |
| const details = await prisma.transaksi_detail.findMany({ | |
| where: { transaksi: { tanggal: { gte: tglMulai, lte: endOfDay }, deleted_at: null, status: { not: 'void' } } }, | |
| include: { produk: true }, | |
| }); | |
| let totalOmset = 0; | |
| let totalHpp = 0; | |
| details.forEach((d) => { | |
| totalOmset += Number(d.subtotal); | |
| const hppVal = Number(d.produk?.harga_beli ?? d.harga_satuan) * Number(d.qty); | |
| totalHpp += hppVal; | |
| }); | |
| const labaKotor = totalOmset - totalHpp; | |
| const margin = totalOmset > 0 ? (labaKotor / totalOmset) * 100 : 0; | |
| doc.fontSize(10).text('Ringkasan Laba Rugi:', { underline: true }); | |
| doc.moveDown(0.5); | |
| doc.fontSize(9).text(`Total Omzet Penjualan: Rp ${totalOmset.toLocaleString('id-ID')}`); | |
| doc.fontSize(9).text(`Total Harga Pokok Penjualan (HPP): Rp ${totalHpp.toLocaleString('id-ID')}`); | |
| doc.moveDown(0.5); | |
| doc.font('Helvetica-Bold').fontSize(11).text(`LABA KOTOR: Rp ${labaKotor.toLocaleString('id-ID')}`); | |
| doc.font('Helvetica'); | |
| doc.fontSize(9).text(`Margin Laba: ${margin.toFixed(2)}%`); | |
| } else if (jenis === 'mutasi-stok') { | |
| const logs = await prisma.stok_log.findMany({ | |
| where: { created_at: { gte: tglMulai, lte: endOfDay } }, | |
| include: { produk: true }, | |
| orderBy: { created_at: 'desc' }, | |
| }); | |
| doc.fontSize(10).text('Mutasi Stok Barang:', { underline: true }); | |
| doc.moveDown(0.5); | |
| logs.forEach((log, idx) => { | |
| doc.fontSize(8).text(`${idx + 1}. [${new Date(log.created_at).toISOString().replace('T', ' ').substring(0, 19)}] ${log.produk.nama} | Tipe: ${log.tipe} | Qty: ${Number(log.qty)} | Stok: ${Number(log.stok_sebelum)} -> ${Number(log.stok_sesudah)} | Catatan: ${log.catatan || '-'}`); | |
| }); | |
| } | |
| doc.end(); | |
| // Tunggu stream selesai menulis file ke disk | |
| await new Promise<void>((resolve) => stream.on('finish', () => resolve())); | |
| } | |
| // Upload ke Supabase Storage | |
| let fileBuffer: Buffer | null = null; | |
| try { | |
| fileBuffer = fs.readFileSync(filePath); | |
| } catch (readErr) { | |
| console.error('Gagal membaca file laporan:', readErr); | |
| } | |
| let downloadUrl = ''; | |
| if (fileBuffer) { | |
| const mimeType = format === 'pdf' ? 'application/pdf' : 'text/csv'; | |
| const supabaseUrl = await uploadBufferToSupabase('exports', filename, fileBuffer, mimeType); | |
| if (supabaseUrl) { | |
| downloadUrl = supabaseUrl; | |
| } | |
| } | |
| if (!downloadUrl) { | |
| const host = process.env.BACKEND_URL || 'http://localhost:5000'; | |
| downloadUrl = `${host}/exports/${filename}`; | |
| } | |
| return { | |
| filename, | |
| url: downloadUrl, | |
| }; | |
| } | |
| } | |