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