Tokiva-backend-Node / src /services /stok.service.ts
anoderb
feat: initial commit of Tokiva POS Node backend for Hugging Face
ef874e6
Raw
History Blame Contribute Delete
12.1 kB
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,
},
});
}
}