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` 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 = { 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 = {}; 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((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, }; } }