Spaces:
Sleeping
Sleeping
| import prisma from '../utils/db'; | |
| import { DiskonService } from './diskon.service'; | |
| import { StokService } from './stok.service'; | |
| import { catatAudit } from '../utils/audit'; | |
| import { generateNoTransaksi, generateNoRetur } from '../utils/kodeGenerator'; | |
| import { APIError } from '../utils/errors'; | |
| export class TransaksiService { | |
| static async prosesTransaksi(params: { | |
| data: any; | |
| actorId: number; | |
| ip: string; | |
| userAgent: string; | |
| }) { | |
| const { data, actorId, ip, userAgent } = params; | |
| // 1. Pastikan shift kasir aktif | |
| const shift = await prisma.shift.findFirst({ | |
| where: { | |
| user_id: actorId, | |
| status: 'buka', | |
| }, | |
| }); | |
| if (!shift) { | |
| throw new APIError('Shift kasir Anda belum dibuka. Silakan buka shift terlebih dahulu.', 400); | |
| } | |
| // 2. Hitung harga dan diskon menggunakan diskon_service | |
| const itemsInput = data.items.map((item: any) => ({ | |
| produk_id: item.produk_id, | |
| qty: item.qty, | |
| })); | |
| const calc = await DiskonService.hitungDiskonDanHarga(itemsInput, data.member_id); | |
| const totalAkhir = calc.total_akhir; | |
| const subtotal = calc.subtotal_dasar; | |
| const diskonTotal = calc.total_diskon_keseluruhan; | |
| // 3. Validasi Pembayaran | |
| const nominalBayar = data.pembayaran.reduce((acc: number, p: any) => acc + p.nominal, 0); | |
| if (nominalBayar < totalAkhir) { | |
| throw new APIError(`Nominal pembayaran kurang. Dibutuhkan: Rp ${totalAkhir.toLocaleString('id-ID')}, Dibayarkan: Rp ${nominalBayar.toLocaleString('id-ID')}`, 400); | |
| } | |
| // Cek Pembayaran bertipe BON | |
| const bonPembayaran = data.pembayaran.find((p: any) => p.metode === 'bon'); | |
| let member: any = null; | |
| if (bonPembayaran) { | |
| if (!data.member_id) { | |
| throw new APIError('Pembayaran tipe Kasbon (Bon) memerlukan pendaftaran Member/Pelanggan.', 400); | |
| } | |
| member = await prisma.member.findFirst({ | |
| where: { id: data.member_id, aktif: 1 }, | |
| }); | |
| if (!member) { | |
| throw new APIError('Member tidak valid atau tidak aktif.', 400); | |
| } | |
| // Validasi limit bon | |
| const sisaLimit = Number(member.limit_bon) - Number(member.total_bon); | |
| if (bonPembayaran.nominal > sisaLimit) { | |
| throw new APIError(`Nominal bon Rp ${bonPembayaran.nominal.toLocaleString('id-ID')} melebihi sisa limit kredit member. Sisa limit: Rp ${sisaLimit.toLocaleString('id-ID')}`, 400); | |
| } | |
| } | |
| const statusTrans = bonPembayaran ? 'bon' : 'lunas'; | |
| // 4. Generate Nomor Transaksi | |
| const noTransaksi = await generateNoTransaksi(); | |
| // Hitung kembalian | |
| const kembalian = nominalBayar - totalAkhir; | |
| // 5. Buat Transaksi (gunakan transaction block) | |
| const result = await prisma.$transaction(async (tx) => { | |
| const txRecord = await tx.transaksi.create({ | |
| data: { | |
| no_transaksi: noTransaksi, | |
| shift_id: shift.id, | |
| user_id: actorId, | |
| member_id: data.member_id ?? null, | |
| tanggal: new Date(), | |
| waktu: new Date(), | |
| subtotal: subtotal, | |
| diskon_total: diskonTotal, | |
| diskon_member: data.member_id ? calc.total_diskon_transaksi : 0, | |
| pajak: 0, | |
| total: totalAkhir, | |
| bayar: nominalBayar, | |
| kembalian: kembalian, | |
| status: statusTrans as any, | |
| is_synced: 1, | |
| local_id: data.local_id ?? null, | |
| catatan: data.catatan ?? null, | |
| }, | |
| }); | |
| // 6. Simpan detail transaksi + Potong Stok (FEFO) | |
| for (const calcItem of calc.cart_items) { | |
| const prod = calcItem.produk; | |
| const reqQty = calcItem.qty; | |
| const hargaOriginal = calcItem.harga_original; | |
| const diskonItem = calcItem.diskon_item; | |
| const itemDiskonId = calcItem.diskon_id; | |
| // Potong stok secara FEFO | |
| const deductedBatches = await StokService.kurangiStokFEFO({ | |
| produkId: prod.id, | |
| qtyToDeduct: reqQty, | |
| userId: actorId, | |
| referensi: noTransaksi, | |
| catatan: `Penjualan #${noTransaksi}`, | |
| }); | |
| // Simpan detail transaksi per batch untuk keakuratan tracking FEFO | |
| for (const dBatch of deductedBatches) { | |
| const proporsi = dBatch.qty / reqQty; | |
| await tx.transaksi_detail.create({ | |
| data: { | |
| transaksi_id: txRecord.id, | |
| produk_id: prod.id, | |
| nama_produk: prod.nama, | |
| qty: dBatch.qty, | |
| satuan: prod.satuan, | |
| harga_satuan: hargaOriginal, | |
| diskon_item: diskonItem * proporsi, | |
| subtotal: (hargaOriginal * dBatch.qty) - (diskonItem * proporsi), | |
| diskon_id: itemDiskonId ?? null, | |
| batch_id: dBatch.batchId, | |
| }, | |
| }); | |
| } | |
| } | |
| // 7. Simpan Pembayaran | |
| const shiftUpdate: any = {}; | |
| for (const p of data.pembayaran) { | |
| await tx.pembayaran.create({ | |
| data: { | |
| transaksi_id: txRecord.id, | |
| metode: p.metode, | |
| nominal: p.nominal, | |
| referensi: p.referensi ?? null, | |
| }, | |
| }); | |
| // Akumulasikan ke totals shift | |
| if (p.metode === 'tunai') { | |
| shiftUpdate.total_tunai = Number(shift.total_tunai) + p.nominal; | |
| } else if (p.metode === 'qris') { | |
| shiftUpdate.total_qris = Number(shift.total_qris) + p.nominal; | |
| } else if (p.metode === 'transfer') { | |
| shiftUpdate.total_transfer = Number(shift.total_transfer) + p.nominal; | |
| } else if (p.metode === 'bon') { | |
| shiftUpdate.total_bon = Number(shift.total_bon) + p.nominal; | |
| } | |
| } | |
| // Jika kembalian dibayarkan dari tunai, kurangi total_tunai shift | |
| if (kembalian > 0) { | |
| shiftUpdate.total_tunai = (shiftUpdate.total_tunai ?? Number(shift.total_tunai)) - kembalian; | |
| } | |
| await tx.shift.update({ | |
| where: { id: shift.id }, | |
| data: { | |
| ...shiftUpdate, | |
| total_transaksi: Number(shift.total_transaksi) + 1, | |
| total_diskon: Number(shift.total_diskon) + diskonTotal, | |
| }, | |
| }); | |
| // 8. Jika status BON, buat pencatatan piutang (bon) | |
| if (statusTrans === 'bon' && bonPembayaran) { | |
| const jatuhTempo = new Date(); | |
| jatuhTempo.setDate(jatuhTempo.getDate() + 30); // Default 30 hari | |
| await tx.bon.create({ | |
| data: { | |
| transaksi_id: txRecord.id, | |
| member_id: data.member_id, | |
| total_bon: bonPembayaran.nominal, | |
| sisa_bon: bonPembayaran.nominal, | |
| jatuh_tempo: jatuhTempo, | |
| status: 'aktif', | |
| }, | |
| }); | |
| // Tambahkan nominal bon ke total_bon milik member | |
| await tx.member.update({ | |
| where: { id: data.member_id }, | |
| data: { | |
| total_bon: Number(member.total_bon) + bonPembayaran.nominal, | |
| }, | |
| }); | |
| } | |
| // 9. Poin Member (Loyalty Program) | |
| if (data.member_id) { | |
| const dbMember = await tx.member.findUnique({ where: { id: data.member_id } }); | |
| if (dbMember) { | |
| const poinSetting = await tx.pengaturan.findUnique({ | |
| where: { kunci: 'poin_member' }, | |
| }); | |
| let perRp = 10000; | |
| let poinPerStep = 1; | |
| if (poinSetting) { | |
| try { | |
| const val = JSON.parse(poinSetting.nilai); | |
| perRp = val.per_rp ?? 10000; | |
| poinPerStep = val.poin ?? 1; | |
| } catch (e) {} | |
| } | |
| const poinBaru = Math.floor(totalAkhir / perRp) * poinPerStep; | |
| if (poinBaru > 0) { | |
| await tx.member.update({ | |
| where: { id: data.member_id }, | |
| data: { | |
| total_poin: Number(dbMember.total_poin) + poinBaru, | |
| }, | |
| }); | |
| } | |
| } | |
| } | |
| return txRecord; | |
| }, { | |
| timeout: 30000 | |
| }); | |
| // 10. Audit Log | |
| await catatAudit({ | |
| userId: actorId, | |
| aksi: 'create', | |
| entitas: 'transaksi', | |
| entitasId: result.id, | |
| dataBaru: { no_transaksi: noTransaksi, total: totalAkhir, status: statusTrans }, | |
| ipAddress: ip, | |
| userAgent, | |
| }); | |
| return await prisma.transaksi.findUnique({ | |
| where: { id: result.id }, | |
| include: { | |
| transaksi_detail: true, | |
| pembayaran: true, | |
| }, | |
| }); | |
| } | |
| static async prosesVoidTransaksi( | |
| transaksiId: number, | |
| alasan: string, | |
| actorId: number, | |
| ip: string, | |
| userAgent: string | |
| ) { | |
| const transaksi = await prisma.transaksi.findUnique({ | |
| where: { id: transaksiId }, | |
| include: { | |
| transaksi_detail: true, | |
| pembayaran: true, | |
| }, | |
| }); | |
| if (!transaksi) { | |
| throw new APIError('Transaksi tidak ditemukan', 404); | |
| } | |
| if (transaksi.status === 'void') { | |
| throw new APIError('Transaksi ini sudah berstatus void sebelumnya', 400); | |
| } | |
| await prisma.$transaction(async (tx) => { | |
| // 1. Kembalikan stok yang dipotong | |
| for (const detail of transaksi.transaksi_detail) { | |
| const produk = await tx.produk.findUnique({ where: { id: detail.produk_id } }); | |
| if (produk) { | |
| const stokSebelum = Number(produk.stok); | |
| const stokSesudah = stokSebelum + Number(detail.qty); | |
| await tx.produk.update({ | |
| where: { id: detail.produk_id }, | |
| data: { stok: stokSesudah }, | |
| }); | |
| // Kembalikan ke batch semula jika ada batch_id | |
| if (detail.batch_id) { | |
| const batch = await tx.stok_batch.findUnique({ where: { id: detail.batch_id } }); | |
| if (batch) { | |
| await tx.stok_batch.update({ | |
| where: { id: detail.batch_id }, | |
| data: { qty_sisa: Number(batch.qty_sisa) + Number(detail.qty) }, | |
| }); | |
| } | |
| } | |
| // Catat log mutasi retur/penyesuaian stok | |
| await tx.stok_log.create({ | |
| data: { | |
| produk_id: detail.produk_id, | |
| batch_id: detail.batch_id, | |
| tipe: 'retur', | |
| qty: detail.qty, | |
| stok_sebelum: stokSebelum, | |
| stok_sesudah: stokSesudah, | |
| referensi: `VOID-${transaksi.no_transaksi}`, | |
| user_id: actorId, | |
| catatan: `Void Transaksi #${transaksi.no_transaksi}. Alasan: ${alasan}`, | |
| }, | |
| }); | |
| } | |
| } | |
| // 2. Jika ada BON, batalkan bon-nya | |
| if (transaksi.status === 'bon') { | |
| const bon = await tx.bon.findFirst({ where: { transaksi_id: transaksiId } }); | |
| if (bon) { | |
| await tx.bon.update({ | |
| where: { id: bon.id }, | |
| data: { status: 'lunas' }, | |
| }); | |
| // Potong total_bon di profile member | |
| const member = await tx.member.findUnique({ where: { id: transaksi.member_id! } }); | |
| if (member) { | |
| await tx.member.update({ | |
| where: { id: transaksi.member_id! }, | |
| data: { | |
| total_bon: Math.max(0, Number(member.total_bon) - Number(bon.sisa_bon)), | |
| }, | |
| }); | |
| } | |
| } | |
| } | |
| // 3. Potong Poin Member yang didapat dari transaksi ini | |
| if (transaksi.member_id) { | |
| const member = await tx.member.findUnique({ where: { id: transaksi.member_id } }); | |
| if (member) { | |
| const poinSetting = await tx.pengaturan.findUnique({ | |
| where: { kunci: 'poin_member' }, | |
| }); | |
| let perRp = 10000; | |
| let poinPerStep = 1; | |
| if (poinSetting) { | |
| try { | |
| const val = JSON.parse(poinSetting.nilai); | |
| perRp = val.per_rp ?? 10000; | |
| poinPerStep = val.poin ?? 1; | |
| } catch (e) {} | |
| } | |
| const poinVoid = Math.floor(Number(transaksi.total) / perRp) * poinPerStep; | |
| await tx.member.update({ | |
| where: { id: transaksi.member_id }, | |
| data: { | |
| total_poin: Math.max(0, Number(member.total_poin) - poinVoid), | |
| }, | |
| }); | |
| } | |
| } | |
| // 4. Sesuaikan totals shift kasir | |
| const shift = await tx.shift.findUnique({ where: { id: transaksi.shift_id } }); | |
| if (shift) { | |
| const shiftUpdate: any = {}; | |
| for (const p of transaksi.pembayaran) { | |
| if (p.metode === 'tunai') { | |
| shiftUpdate.total_tunai = Number(shift.total_tunai) - Number(p.nominal); | |
| } else if (p.metode === 'qris') { | |
| shiftUpdate.total_qris = Number(shift.total_qris) - Number(p.nominal); | |
| } else if (p.metode === 'transfer') { | |
| shiftUpdate.total_transfer = Number(shift.total_transfer) - Number(p.nominal); | |
| } else if (p.metode === 'bon') { | |
| shiftUpdate.total_bon = Number(shift.total_bon) - Number(p.nominal); | |
| } | |
| } | |
| // Kembalikan penyesuaian jika ada kembalian tunai | |
| if (Number(transaksi.kembalian) > 0) { | |
| shiftUpdate.total_tunai = (shiftUpdate.total_tunai ?? Number(shift.total_tunai)) + Number(transaksi.kembalian); | |
| } | |
| await tx.shift.update({ | |
| where: { id: transaksi.shift_id }, | |
| data: { | |
| ...shiftUpdate, | |
| total_transaksi: Math.max(0, Number(shift.total_transaksi) - 1), | |
| total_diskon: Math.max(0, Number(shift.total_diskon) - Number(transaksi.diskon_total)), | |
| }, | |
| }); | |
| } | |
| // 5. Set status transaksi menjadi void | |
| await tx.transaksi.update({ | |
| where: { id: transaksiId }, | |
| data: { | |
| status: 'void', | |
| deleted_at: new Date(), | |
| }, | |
| }); | |
| // 6. Catat Notifikasi | |
| await tx.notifikasi.create({ | |
| data: { | |
| tipe: 'transaksi_void', | |
| judul: 'Transaksi Dibatalkan (Void)', | |
| pesan: `Transaksi #${transaksi.no_transaksi} dibatalkan oleh user ID ${actorId}. Alasan: ${alasan}`, | |
| referensi_id: transaksi.id, | |
| }, | |
| }); | |
| }); | |
| // 7. Audit Log | |
| await catatAudit({ | |
| userId: actorId, | |
| aksi: 'void', | |
| entitas: 'transaksi', | |
| entitasId: transaksi.id, | |
| dataLama: { status: 'lunas/bon' }, | |
| dataBaru: { status: 'void', alasan }, | |
| ipAddress: ip, | |
| userAgent, | |
| }); | |
| return await prisma.transaksi.findUnique({ | |
| where: { id: transaksiId }, | |
| include: { | |
| transaksi_detail: true, | |
| pembayaran: true, | |
| }, | |
| }); | |
| } | |
| static async prosesReturBarang( | |
| data: any, | |
| actorId: number, | |
| ip: string, | |
| userAgent: string | |
| ) { | |
| // Validate transaction | |
| const trans = await prisma.transaksi.findUnique({ | |
| where: { id: data.transaksi_id }, | |
| }); | |
| if (!trans) { | |
| throw new APIError('Transaksi asal tidak ditemukan', 404); | |
| } | |
| // Validate product is in transaction | |
| const detail = await prisma.transaksi_detail.findFirst({ | |
| where: { | |
| transaksi_id: data.transaksi_id, | |
| produk_id: data.produk_id, | |
| }, | |
| }); | |
| if (!detail) { | |
| throw new APIError('Produk tersebut tidak ada dalam riwayat transaksi ini', 400); | |
| } | |
| // Validate quantity retur | |
| const sumResult = await prisma.transaksi_retur.aggregate({ | |
| where: { | |
| transaksi_id: data.transaksi_id, | |
| produk_id: data.produk_id, | |
| }, | |
| _sum: { | |
| qty: true, | |
| }, | |
| }); | |
| const alreadyReturned = Number(sumResult._sum.qty ?? 0); | |
| const maxReturnable = Number(detail.qty) - alreadyReturned; | |
| if (data.qty > maxReturnable) { | |
| throw new APIError(`Jumlah retur melebihi jumlah pembelian. Maksimal yang bisa diretur: ${maxReturnable} ${detail.satuan}`, 400); | |
| } | |
| // Generate retur number | |
| const noRetur = await generateNoRetur(); | |
| const result = await prisma.$transaction(async (tx) => { | |
| // Create retur record | |
| const retur = await tx.transaksi_retur.create({ | |
| data: { | |
| transaksi_id: data.transaksi_id, | |
| no_retur: noRetur, | |
| user_id: actorId, | |
| produk_id: data.produk_id, | |
| qty: data.qty, | |
| alasan: data.alasan, | |
| tipe: data.tipe, | |
| nominal_kembali: data.nominal_kembali ?? 0, | |
| stok_kembali: data.stok_kembali ?? 1, | |
| }, | |
| }); | |
| // Restitusi stok jika stok_kembali = 1 | |
| if (data.stok_kembali === 1) { | |
| const produk = await tx.produk.findUnique({ where: { id: data.produk_id } }); | |
| if (produk) { | |
| const stokSebelum = Number(produk.stok); | |
| const stokSesudah = stokSebelum + data.qty; | |
| await tx.produk.update({ | |
| where: { id: data.produk_id }, | |
| data: { stok: stokSesudah }, | |
| }); | |
| // Kembalikan ke batch semula | |
| if (detail.batch_id) { | |
| const batch = await tx.stok_batch.findUnique({ where: { id: detail.batch_id } }); | |
| if (batch) { | |
| await tx.stok_batch.update({ | |
| where: { id: detail.batch_id }, | |
| data: { qty_sisa: Number(batch.qty_sisa) + data.qty }, | |
| }); | |
| } | |
| } | |
| // Catat log | |
| await tx.stok_log.create({ | |
| data: { | |
| produk_id: data.produk_id, | |
| batch_id: detail.batch_id, | |
| tipe: 'retur', | |
| qty: data.qty, | |
| stok_sebelum: stokSebelum, | |
| stok_sesudah: stokSesudah, | |
| referensi: noRetur, | |
| user_id: actorId, | |
| catatan: `Retur Barang dari Transaksi #${trans.no_transaksi}. Alasan: ${data.alasan}`, | |
| }, | |
| }); | |
| } | |
| } | |
| return retur; | |
| }); | |
| await catatAudit({ | |
| userId: actorId, | |
| aksi: 'create', | |
| entitas: 'transaksi_retur', | |
| entitasId: result.id, | |
| dataBaru: { no_retur: noRetur, qty: Number(data.qty), tipe: data.tipe }, | |
| ipAddress: ip, | |
| userAgent, | |
| }); | |
| return result; | |
| } | |
| } | |