import prisma from '../utils/db'; import { catatAudit } from '../utils/audit'; import { APIError } from '../utils/errors'; export class StokService { static async tambahStok(params: { produkId: number; qty: number; hargaBeli: number; batchNo?: string | null; expiredDate?: Date | null; supplierId?: number | null; userId?: number | null; catatan?: string | null; referensi?: string | null; }) { const { produkId, qty, hargaBeli, batchNo, expiredDate, supplierId, userId, catatan, referensi } = params; // 1. Ambil data produk const produk = await prisma.produk.findUnique({ where: { id: produkId }, }); if (!produk) { throw new APIError(`Produk dengan ID ${produkId} tidak ditemukan.`, 404); } // 2. Buat StokBatch baru const batch = await prisma.stok_batch.create({ data: { produk_id: produkId, batch_no: batchNo ?? null, qty_masuk: qty, qty_sisa: qty, harga_beli: hargaBeli, expired_date: expiredDate ?? null, supplier_id: supplierId ?? null, catatan: catatan ?? null, }, }); // 3. Update stok di tabel produk const stokSebelum = Number(produk.stok); const stokSesudah = stokSebelum + qty; // Update harga beli rata-rata produk (Moving Average) let newHargaBeli = hargaBeli; if (stokSebelum > 0) { const totalNilaiLama = stokSebelum * Number(produk.harga_beli); const totalNilaiBaru = qty * hargaBeli; newHargaBeli = (totalNilaiLama + totalNilaiBaru) / stokSesudah; } await prisma.produk.update({ where: { id: produkId }, data: { stok: stokSesudah, harga_beli: newHargaBeli, }, }); // 4. Catat mutasi di stok_log const log = await prisma.stok_log.create({ data: { produk_id: produkId, batch_id: batch.id, tipe: 'masuk', qty: qty, stok_sebelum: stokSebelum, stok_sesudah: stokSesudah, referensi: referensi ?? null, user_id: userId ?? null, catatan: catatan ?? null, }, }); return { batch, log }; } static async kurangiStokFEFO(params: { produkId: number; qtyToDeduct: number; userId?: number | null; referensi?: string | null; catatan?: string | null; }) { const { produkId, qtyToDeduct, userId, referensi, catatan } = params; const produk = await prisma.produk.findUnique({ where: { id: produkId }, }); if (!produk) { throw new APIError(`Produk dengan ID ${produkId} tidak ditemukan.`, 404); } const stokSebelum = Number(produk.stok); if (stokSebelum < qtyToDeduct) { throw new APIError(`Stok produk '${produk.nama}' tidak mencukupi. Dibutuhkan: ${qtyToDeduct}, Tersedia: ${stokSebelum}`, 400); } const stokSesudah = stokSebelum - qtyToDeduct; // Update stok produk di database await prisma.produk.update({ where: { id: produkId }, data: { stok: stokSesudah }, }); // Ambil batch stok yang aktif (qty_sisa > 0) terurut FEFO: // 1. Expired date terdekat (yang tidak null) // 2. Batch tanpa expired date (null) ditaruh di paling belakang const batches = (await prisma.$queryRawUnsafe( `SELECT * FROM stok_batch WHERE produk_id = ? AND qty_sisa > 0 ORDER BY (expired_date IS NULL) ASC, expired_date ASC`, produkId )) as any[]; let remainingQty = qtyToDeduct; const deductedBatches: Array<{ batchId: number; qty: number }> = []; for (const batch of batches) { if (remainingQty <= 0) break; const qtySisa = Number(batch.qty_sisa); if (qtySisa >= remainingQty) { // Kurangi dari batch ini sepenuhnya await prisma.stok_batch.update({ where: { id: batch.id }, data: { qty_sisa: qtySisa - remainingQty }, }); deductedBatches.push({ batchId: batch.id, qty: remainingQty }); // Catat log mutasi await prisma.stok_log.create({ data: { produk_id: produkId, batch_id: batch.id, tipe: 'keluar', qty: -remainingQty, stok_sebelum: stokSebelum, stok_sesudah: stokSesudah, referensi: referensi ?? null, user_id: userId ?? null, catatan: catatan ?? null, }, }); remainingQty = 0; } else { // Kurangi semua qty sisa di batch ini const deductAmount = qtySisa; await prisma.stok_batch.update({ where: { id: batch.id }, data: { qty_sisa: 0 }, }); deductedBatches.push({ batchId: batch.id, qty: deductAmount }); remainingQty -= deductAmount; // Catat log mutasi await prisma.stok_log.create({ data: { produk_id: produkId, batch_id: batch.id, tipe: 'keluar', qty: -deductAmount, stok_sebelum: stokSebelum, stok_sesudah: stokSesudah, referensi: referensi ?? null, user_id: userId ?? null, catatan: catatan ?? null, }, }); } } // Cek minimum threshold stok const updatedProduk = await prisma.produk.findUnique({ where: { id: produkId } }); if (updatedProduk) { await this.cekThresholdStok(updatedProduk); } return deductedBatches; } static async cekThresholdStok(produk: any) { const stokVal = Number(produk.stok); const stokMinVal = Number(produk.stok_min); if (stokVal <= stokMinVal) { const tipeNotif = stokVal <= 0 ? 'stok_habis' : 'stok_rendah'; const judul = stokVal <= 0 ? `Stok Habis: ${produk.nama}` : `Stok Rendah: ${produk.nama}`; const pesan = `Stok produk ${produk.nama} tersisa ${stokVal} ${produk.satuan}. Batas minimum: ${stokMinVal} ${produk.satuan}.`; // Cek apakah notifikasi serupa yang belum dibaca sudah ada const exists = await prisma.notifikasi.findFirst({ where: { tipe: tipeNotif, referensi_id: produk.id, is_dibaca: 0, }, }); if (!exists) { await prisma.notifikasi.create({ data: { tipe: tipeNotif, judul, pesan, referensi_id: produk.id, }, }); } } } static async tambahBarangMasuk(data: any, actorId: number, ip: string, userAgent: string) { const { batch } = await this.tambahStok({ produkId: data.produk_id, qty: data.qty, hargaBeli: data.harga_beli, batchNo: data.batch_no, expiredDate: data.expired_date, supplierId: data.supplier_id, userId: actorId, catatan: data.catatan, referensi: 'INCOMING', }); await catatAudit({ userId: actorId, aksi: 'create', entitas: 'stok_batch', entitasId: batch.id, dataBaru: { produk_id: data.produk_id, qty: Number(data.qty), batch_no: data.batch_no }, ipAddress: ip, userAgent, }); return batch; } static async buatStokOpname(data: any, actorId: number, ip: string, userAgent: string) { const produk = await prisma.produk.findFirst({ where: { id: data.produk_id, deleted_at: null }, }); if (!produk) { throw new APIError('Produk tidak ditemukan', 404); } const stokSistem = Number(produk.stok); const selisih = data.stok_fisik - stokSistem; const opname = await prisma.stok_opname.create({ data: { produk_id: data.produk_id, user_id: actorId, stok_sistem: stokSistem, stok_fisik: data.stok_fisik, selisih: selisih, alasan: data.alasan ?? null, status: 'draft', }, }); await catatAudit({ userId: actorId, aksi: 'create', entitas: 'stok_opname', entitasId: opname.id, dataBaru: { produk_id: data.produk_id, stok_fisik: Number(data.stok_fisik), selisih: Number(selisih) }, ipAddress: ip, userAgent, }); return opname; } static async approveStokOpname(opnameId: number, actorId: number, ip: string, userAgent: string) { const opname = await prisma.stok_opname.findUnique({ where: { id: opnameId }, }); if (!opname) { throw new APIError('Pencatatan stok opname tidak ditemukan', 404); } if (opname.status === 'approved') { throw new APIError('Stok opname ini sudah disetujui sebelumnya', 400); } const produk = await prisma.produk.findUnique({ where: { id: opname.produk_id }, }); if (!produk) { throw new APIError('Produk terkait tidak ditemukan', 404); } const stokSebelum = Number(produk.stok); const stokSesudah = Number(opname.stok_fisik); // Apply adjustments in main product stock await prisma.produk.update({ where: { id: opname.produk_id }, data: { stok: stokSesudah }, }); // Log stock change await prisma.stok_log.create({ data: { produk_id: opname.produk_id, tipe: 'opname', qty: opname.selisih, stok_sebelum: stokSebelum, stok_sesudah: stokSesudah, referensi: `OPNAME-${opname.id}`, user_id: actorId, catatan: `Persetujuan Opname. Alasan: ${opname.alasan || '-'}`, }, }); const selisihVal = Number(opname.selisih); if (selisihVal < 0) { // Negative discrepancy, reduce from latest batch let remainingReduction = Math.abs(selisihVal); const batches = await prisma.stok_batch.findMany({ where: { produk_id: opname.produk_id, qty_sisa: { gt: 0 } }, orderBy: { created_at: 'desc' }, }); for (const batch of batches) { if (remainingReduction <= 0) break; const qtySisa = Number(batch.qty_sisa); if (qtySisa >= remainingReduction) { await prisma.stok_batch.update({ where: { id: batch.id }, data: { qty_sisa: qtySisa - remainingReduction }, }); remainingReduction = 0; } else { await prisma.stok_batch.update({ where: { id: batch.id }, data: { qty_sisa: 0 }, }); remainingReduction -= qtySisa; } } } else if (selisihVal > 0) { // Excess stock, add as an adjustment batch await prisma.stok_batch.create({ data: { produk_id: opname.produk_id, batch_no: 'OPNAME-ADJ', qty_masuk: selisihVal, qty_sisa: selisihVal, harga_beli: produk.harga_beli, catatan: `Penyesuaian lebih stok dari opname #${opname.id}`, }, }); } // Update opname status const updatedOpname = await prisma.stok_opname.update({ where: { id: opnameId }, data: { status: 'approved' }, }); // Check threshold again const updatedProduk = await prisma.produk.findUnique({ where: { id: opname.produk_id } }); if (updatedProduk) { await this.cekThresholdStok(updatedProduk); } await catatAudit({ userId: actorId, aksi: 'update', entitas: 'stok_opname', entitasId: opname.id, dataLama: { status: 'draft' }, dataBaru: { status: 'approved' }, ipAddress: ip, userAgent, }); return updatedOpname; } static async getStokLogs(skip = 0, limit = 20, produkId?: number, tipe?: string) { const where: any = {}; if (produkId) where.produk_id = produkId; if (tipe) where.tipe = tipe; return await prisma.stok_log.findMany({ where, orderBy: { created_at: 'desc' }, skip, take: limit, include: { produk: true, }, }); } static async getStokBatches(skip = 0, limit = 20, produkId?: number) { const where: any = { qty_sisa: { gt: 0 }, }; if (produkId) where.produk_id = produkId; return await prisma.stok_batch.findMany({ where, orderBy: { expired_date: 'asc' }, skip, take: limit, include: { produk: true, }, }); } }