Spaces:
Sleeping
Sleeping
| 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, | |
| }, | |
| }); | |
| } | |
| } | |