/*************************************************************** * STOK GUDANG OTOMATIS v2.0 — Google Sheets + Apps Script * * Sheet (nama harus persis sama, atau ubah di CONFIG): * - Master : daftar barang + stok. Nama/Size/Type/Brand/Satuan * BOLEH diedit langsung. Kode & Qty dilindungi (robot). * - Log Masuk : admin isi barang masuk * - Log Keluar : admin isi barang keluar * - Arsip : barang yang stoknya 0 (tampil otomatis) * * CARA KERJA v2.0 (beda dengan v1.x): * Master TIDAK "ditambah/dikurangi". Setiap ada perubahan, stok * DIHITUNG ULANG dari nol: Stok = total Log Masuk - total Log Keluar. * Akibatnya Ctrl+Z, edit qty, hapus baris — semuanya aman, berapa kali pun. * * CARA PASANG (sekali saja): * 1. Dari Google Sheet-nya: Extensions -> Apps Script * 2. Hapus semua isi editor, paste SELURUH file ini, Save (Ctrl+S) * 3. Pilih fungsi "setup" di dropdown atas -> Run -> izinkan akses * 4. Balik ke sheet, refresh. Selesai. * (setup sudah sekalian memasang trigger. Aman dijalankan ulang.) ***************************************************************/ /* ============================================================ * VERSI & RIWAYAT PERUBAHAN * ------------------------------------------------------------ * v2.0 (26/10/2026) - DITULIS ULANG. Master dihitung ulang dari log * setiap ada perubahan (bukan ditambah/dikurangi). * -> Ctrl+Z, edit qty, hapus baris biasa = aman. * -> Edit nama/spek/type/brand/satuan langsung di * Master; perubahan ikut tersalin ke semua log. * -> Salah ketik nama di log bisa dibetulkan di log; * kode "yatim" (tanpa transaksi) hilang sendiri. * -> Stok kurang = peringatan merah (tidak menolak * ketikan, supaya Ctrl+Z selalu bisa jalan). * -> Tidak perlu SS_ID & pasangTrigger terpisah. * Tombol Undo/Edit/Hapus Kode/Proses Tertunda dihapus * (sudah tidak dibutuhkan). Sisa 1 tombol: Hitung Ulang. * v1.0–v1.9 - versi lama (lihat apps_script_gudang.gs) * ============================================================ */ const VERSI = '2.0'; const CONFIG = { SHEET_MASTER: 'Master', SHEET_MASUK: 'Log Masuk', SHEET_KELUAR: 'Log Keluar', SHEET_ARSIP: 'Arsip', KODE_DIGIT: 4, // 0001, 0002, ... (lewat 9999 otomatis jadi 5 digit) WARNA_ERROR: '#f4c7c3', // merah muda = ada masalah WARNA_OK: '#d9ead3', // hijau muda = sudah dihitung // Daftar satuan untuk dropdown (boleh diedit; tetap bisa ngetik satuan lain) SATUAN_LIST: ['pcs', 'unit', 'batang', 'roll', 'set', 'box', 'meter', 'kg', 'liter', 'lembar'], }; const HEADER_MASTER = ['Kode', 'Nama Barang', 'Size/Spec', 'Type', 'Brand', 'Qty', 'Satuan']; const HEADER_MASUK = ['Tanggal', 'Kode Barang', 'Nama Barang', 'Size/Spec', 'Type', 'Brand', 'Qty Masuk', 'Satuan', 'Proyek', 'Keterangan', 'Status']; const HEADER_KELUAR = ['Tanggal', 'Kode', 'Nama Barang', 'Size/Spec', 'Type', 'Brand', 'Qty Keluar', 'Satuan', 'Proyek', 'Keterangan', 'Status']; const HEADER_ARSIP = ['Kode', 'Nama Barang', 'Size/Spec', 'Type', 'Brand', 'Qty', 'Satuan', 'Tanggal Habis']; // Indeks kolom (mulai dari 0) — Log Masuk & Log Keluar urutannya SAMA const L = { TGL: 0, KODE: 1, NAMA: 2, SPEC: 3, TYPE: 4, BRAND: 5, QTY: 6, SATUAN: 7, PROYEK: 8, KET: 9, STATUS: 10 }; const LOG_LEBAR = 11; const MASTER_LEBAR = 7; const ARSIP_LEBAR = 8; /* ============================================================ * SETUP — jalankan sekali (Run -> setup). Aman dijalankan ulang. * ============================================================ */ function setup() { const ss = SpreadsheetApp.getActiveSpreadsheet(); const master = siapkanSheet_(ss, CONFIG.SHEET_MASTER, HEADER_MASTER); const masuk = siapkanSheet_(ss, CONFIG.SHEET_MASUK, HEADER_MASUK); const keluar = siapkanSheet_(ss, CONFIG.SHEET_KELUAR, HEADER_KELUAR); const arsip = siapkanSheet_(ss, CONFIG.SHEET_ARSIP, HEADER_ARSIP); // Kolom kode = TEKS, supaya "0001" tidak berubah jadi 1 master.getRange('A2:A').setNumberFormat('@'); arsip.getRange('A2:A').setNumberFormat('@'); masuk.getRange('B2:B').setNumberFormat('@'); keluar.getRange('B2:B').setNumberFormat('@'); masuk.getRange('A2:A').setNumberFormat('dd/mm/yyyy'); keluar.getRange('A2:A').setNumberFormat('dd/mm/yyyy'); arsip.getRange('H2:H').setNumberFormat('dd/mm/yyyy'); // Dropdown kode di kedua log (isi dari Master; boleh ngetik manual juga) const ruleKode = SpreadsheetApp.newDataValidation() .requireValueInRange(master.getRange('A2:A'), true) .setAllowInvalid(true) .build(); masuk.getRange('B2:B').setDataValidation(ruleKode); keluar.getRange('B2:B').setDataValidation(ruleKode); // Dropdown satuan di kedua log const ruleSatuan = SpreadsheetApp.newDataValidation() .requireValueInList(CONFIG.SATUAN_LIST, true) .setAllowInvalid(true) .build(); masuk.getRange('H2:H').setDataValidation(ruleSatuan); keluar.getRange('H2:H').setDataValidation(ruleSatuan); // Lindungi kolom milik robot (muncul peringatan kalau ada yang mau mengetik di situ). // Sengaja "peringatan", bukan kunci mati: supaya tombol Hitung Ulang tetap jalan // saat diklik admin. Kalaupun tetap ditimpa, robot langsung mengembalikannya. // Kolom Status di log sengaja TIDAK dilindungi, supaya Delete row & paste baris penuh lancar. [master, masuk, keluar, arsip].forEach(function (sh) { sh.getProtections(SpreadsheetApp.ProtectionType.RANGE).forEach(function (p) { if (String(p.getDescription()).indexOf('stokgudang:') === 0) p.remove(); }); }); lindungi_(master, 'A2:A', 'Kode diisi robot'); lindungi_(master, 'F2:F', 'Qty dihitung robot dari log'); lindungi_(arsip, 'A2:A', 'Kode diisi robot'); lindungi_(arsip, 'F2:F', 'Qty dihitung robot dari log'); lindungi_(arsip, 'H2:H', 'Tanggal Habis diisi robot'); // Urutkan tab & buang tab kosong bawaan ("Sheet1" / "Lembar1") [master, masuk, keluar, arsip].forEach(function (sh, i) { ss.setActiveSheet(sh); ss.moveActiveSheet(i + 1); }); ss.getSheets().forEach(function (sh) { if (SHEET_DIURUS.indexOf(sh.getName()) >= 0) return; if (/^(sheet|lembar)\s?\d+$/i.test(sh.getName()) && sh.getLastRow() === 0 && sh.getLastColumn() === 0) { ss.deleteSheet(sh); } }); ss.setActiveSheet(masuk); // Trigger: buang semua trigger lama project ini, pasang yang baru. // - saatEdit : ketik / paste / Ctrl+Z // - saatUbah : hapus/sisip baris (Delete row TIDAK dianggap "edit" oleh Google) ScriptApp.getProjectTriggers().forEach(function (t) { ScriptApp.deleteTrigger(t); }); ScriptApp.newTrigger('saatEdit').forSpreadsheet(ss).onEdit().create(); ScriptApp.newTrigger('saatUbah').forSpreadsheet(ss).onChange().create(); hitungUlang_(ss, null, true); ss.toast('Setup selesai. Stok sudah dihitung dari log.', 'Stok Gudang v' + VERSI, 6); } function siapkanSheet_(ss, nama, header) { let sh = ss.getSheetByName(nama); if (!sh) sh = ss.insertSheet(nama); sh.getRange(1, 1, 1, header.length).setValues([header]) .setFontWeight('bold').setBackground('#eeeeee'); sh.setFrozenRows(1); return sh; } function lindungi_(sheet, a1, keterangan) { sheet.getRange(a1).protect() .setDescription('stokgudang:' + keterangan) .setWarningOnly(true); } /* ============================================================ * TRIGGER — dipanggil otomatis (jangan dijalankan manual) * ============================================================ */ const SHEET_DIURUS = [CONFIG.SHEET_MASTER, CONFIG.SHEET_MASUK, CONFIG.SHEET_KELUAR, CONFIG.SHEET_ARSIP]; // Setiap ada ketikan / paste / Ctrl+Z function saatEdit(e) { if (!e) return; const ss = e.source || SpreadsheetApp.getActiveSpreadsheet(); if (!e.range) { hitungUlang_(ss, null); return; } // jaga-jaga: event tanpa info sel const nama = e.range.getSheet().getName(); if (SHEET_DIURUS.indexOf(nama) < 0) return; // Kalau yang diedit kolom identitas (Nama..Brand) di log, catat barisnya: // baris itu "dicocokkan ulang" (betulkan salah ketik / pindah barang). let edit = null; if (nama === CONFIG.SHEET_MASUK || nama === CONFIG.SHEET_KELUAR) { const c1 = e.range.getColumn(), c2 = c1 + e.range.getNumColumns() - 1; // Hanya Proyek/Keterangan yang diedit -> stok tidak berubah, robot tidak perlu kerja if (c1 >= L.PROYEK + 1 && c2 <= L.KET + 1) return; if (c1 <= L.BRAND + 1 && c2 >= L.NAMA + 1) { const baris = []; const r0 = e.range.getRow(), r1 = r0 + e.range.getNumRows() - 1; for (let r = Math.max(r0, 2); r <= r1; r++) baris.push(r - 2); // baris 2 = indeks 0 edit = { log: nama === CONFIG.SHEET_MASUK ? 'masuk' : 'keluar', baris: baris }; } // Kalau kolom Satuan yang diedit di log, catat barisnya: satuan baru itu // dipakai untuk barang tersebut di Master, Arsip, dan semua baris log. if (c1 <= L.SATUAN + 1 && c2 >= L.SATUAN + 1) { const barisSat = []; const r0 = e.range.getRow(), r1 = r0 + e.range.getNumRows() - 1; for (let r = Math.max(r0, 2); r <= r1; r++) barisSat.push(r - 2); edit = edit || { log: nama === CONFIG.SHEET_MASUK ? 'masuk' : 'keluar', baris: [] }; edit.satuan = barisSat; } } hitungUlang_(ss, edit); } // Perubahan struktur: hapus/sisip baris, dll. Edit biasa sudah ditangani saatEdit. function saatUbah(e) { if (!e || e.changeType === 'EDIT' || e.changeType === 'FORMAT') return; hitungUlang_(e.source || SpreadsheetApp.getActiveSpreadsheet(), null); } /* ============================================================ * MENU * ============================================================ */ function onOpen() { try { SpreadsheetApp.getUi().createMenu('🛠 Modification Tools') .addItem('🔄 Hitung Ulang Stok', 'tombolHitungUlang') .addToUi(); } catch (err) { // Diabaikan (konteks tanpa UI) } } function tombolHitungUlang() { const ss = SpreadsheetApp.getActiveSpreadsheet(); hitungUlang_(ss, null, true); ss.toast('Stok sudah dihitung ulang dari log.', 'Stok Gudang', 4); } /* ============================================================ * BACA -> HITUNG -> TULIS * ============================================================ */ // cetUlang = true: warna Status & Master dicat ulang penuh (setup & tombol Hitung Ulang). // Saat ketikan biasa, hanya sel yang berubah yang dicat, supaya robot lebih cepat. function hitungUlang_(ss, edit, cetUlang) { const lock = LockService.getDocumentLock(); if (!lock.tryLock(30000)) return; // ada proses lain; edit berikutnya akan menghitung ulang try { const sh = { master: ss.getSheetByName(CONFIG.SHEET_MASTER), masuk: ss.getSheetByName(CONFIG.SHEET_MASUK), keluar: ss.getSheetByName(CONFIG.SHEET_KELUAR), arsip: ss.getSheetByName(CONFIG.SHEET_ARSIP), }; if (!sh.master || !sh.masuk || !sh.keluar || !sh.arsip) { throw new Error('Sheet belum lengkap. Jalankan setup dulu.'); } const data = { master: baca_(sh.master, MASTER_LEBAR), arsip: baca_(sh.arsip, ARSIP_LEBAR), masuk: baca_(sh.masuk, LOG_LEBAR), keluar: baca_(sh.keluar, LOG_LEBAR), }; const hasil = hitungStok_(data, edit, new Date()); tulisLog_(sh.masuk, data.masuk, hasil.masuk, cetUlang); tulisLog_(sh.keluar, data.keluar, hasil.keluar, cetUlang); tulisDaftar_(sh.master, data.master, hasil.master, MASTER_LEBAR, cetUlang); tulisDaftar_(sh.arsip, data.arsip, hasil.arsip, ARSIP_LEBAR, cetUlang); } finally { lock.releaseLock(); } } function baca_(sheet, lebar) { const last = sheet.getLastRow(); if (last < 2) return []; return sheet.getRange(2, 1, last - 1, lebar).getValues(); } // Tulis hanya kolom yang memang diurus robot, hanya sel yang berubah. // Kolom Qty, Proyek, Keterangan TIDAK pernah ditulis robot (milik admin). // Sebelum menulis, isi sel dicek ulang: kalau admin baru saja mengetik di situ, // robot mengalah (ketikan admin itu sendiri memicu hitung ulang berikutnya). function tulisLog_(sheet, lama, baru, cetUlang) { const n = baru.rows.length; if (n === 0) return; const blok = [ { c0: L.TGL, c1: L.TGL }, { c0: L.KODE, c1: L.KODE, teks: true }, { c0: L.NAMA, c1: L.BRAND }, { c0: L.SATUAN, c1: L.SATUAN }, { c0: L.STATUS, c1: L.STATUS }, ]; blok.forEach(function (b) { let i = 0; while (i < n) { if (barisSama_(lama[i], baru.rows[i], b.c0, b.c1)) { i++; continue; } let j = i; while (j + 1 < n && !barisSama_(lama[j + 1], baru.rows[j + 1], b.c0, b.c1)) j++; tulisAman_(sheet, i, j, b.c0, b.c1, lama, baru.rows, b.teks); i = j + 1; } }); // Warna kolom Status: hanya baris yang statusnya berubah (atau semua kalau cetUlang) const warnaDari = function (s) { return [s === '' ? null : (s === 'OK' ? CONFIG.WARNA_OK : CONFIG.WARNA_ERROR)]; }; const berubah = function (i) { return cetUlang || !samaNilai_(lama[i][L.STATUS], baru.status[i]); }; let i = 0; while (i < n) { if (!berubah(i)) { i++; continue; } let j = i; while (j + 1 < n && berubah(j + 1)) j++; sheet.getRange(i + 2, L.STATUS + 1, j - i + 1, 1) .setBackgrounds(baru.status.slice(i, j + 1).map(warnaDari)); i = j + 1; } } function barisSama_(a, b, c0, c1) { for (let c = c0; c <= c1; c++) if (!samaNilai_(a[c], b[c])) return false; return true; } // Tulis baris i..j kolom c0..c1, hanya kalau isi sheet sekarang masih sama dengan yang dibaca tadi function tulisAman_(sheet, i, j, c0, c1, lama, rows, teks) { const range = sheet.getRange(i + 2, c0 + 1, j - i + 1, c1 - c0 + 1); const kini = range.getValues(); for (let x = 0; x < kini.length; x++) { for (let c = c0; c <= c1; c++) { if (!samaNilai_(kini[x][c - c0], lama[i + x][c])) return; // admin sedang mengetik di sini } } if (teks) range.setNumberFormat('@'); range.setValues(rows.slice(i, j + 1).map(function (r) { return r.slice(c0, c1 + 1); })); } // Master & Arsip ditulis ulang utuh (urut kode), kalau ada yang berubah. function tulisDaftar_(sheet, lama, baru, lebar, cetUlang) { const nLama = lama.length, nBaru = baru.length; let sama = nLama === nBaru; for (let i = 0; sama && i < nBaru; i++) { if (!barisSama_(lama[i], baru[i], 0, lebar - 1)) sama = false; } if (sama && !cetUlang) return; // tidak ada yang berubah: tidak perlu tulis maupun cat ulang if (!sama) { // Cek ulang: kalau admin baru saja mengedit sheet ini, robot mengalah dulu const kini = baca_(sheet, lebar); if (kini.length !== nLama) return; for (let i = 0; i < nLama; i++) { if (!barisSama_(kini[i], lama[i], 0, lebar - 1)) return; } if (nBaru > 0) { sheet.getRange(2, 1, nBaru, 1).setNumberFormat('@'); sheet.getRange(2, 1, nBaru, lebar).setValues(baru); } if (nLama > nBaru) sheet.getRange(nBaru + 2, 1, nLama - nBaru, lebar).clearContent(); } // Qty minus = merah (stok kurang, cek Log Keluar) const nWarna = Math.max(nLama, nBaru); if (nWarna > 0) { const warna = []; for (let i = 0; i < nWarna; i++) { warna.push([i < nBaru && baru[i][5] < 0 ? CONFIG.WARNA_ERROR : null]); } sheet.getRange(2, 6, nWarna, 1).setBackgrounds(warna); } } function samaNilai_(a, b) { if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime(); if (a instanceof Date || b instanceof Date) return false; if (typeof a === 'number' && typeof b === 'number') return a === b; return String(a === null || a === undefined ? '' : a) === String(b === null || b === undefined ? '' : b); } /* ============================================================ * OTAK HITUNGAN — fungsi murni, tanpa akses Google Sheet. * (Dipisah supaya bisa diuji di luar Google.) * * data.master : [[kode, nama, spec, type, brand, qty, satuan]] * data.arsip : [[kode, nama, spec, type, brand, qty, satuan, tglHabis]] * data.masuk : [[tgl, kode, nama, spec, type, brand, qty, satuan, proyek, ket, status]] * data.keluar : sama dengan masuk * edit : null, atau { log: 'masuk'|'keluar', baris: [indeks...] } * = baris log yang kolom identitasnya baru diedit * ============================================================ */ function hitungStok_(data, edit, sekarang) { const masuk = data.masuk.map(function (r) { return r.slice(); }); const keluar = data.keluar.map(function (r) { return r.slice(); }); const log = { masuk: masuk, keluar: keluar }; // 1. Katalog barang (kode -> identitas) dari Master + Arsip. // Inilah "ingatan" nama/spek yang mungkin sudah diedit admin di Master. const katalog = {}; data.master.concat(data.arsip).forEach(function (r) { const k = padKode_(r[0]); if (k === '' || katalog[k]) return; katalog[k] = { nama: teks_(r[1]), spec: teks_(r[2]), type: teks_(r[3]), brand: teks_(r[4]), satuan: teks_(r[6]) }; }); // Rapikan kode di log ("1" -> "0001") [masuk, keluar].forEach(function (rows) { rows.forEach(function (r) { r[L.KODE] = padKode_(r[L.KODE]); }); }); const qtyValid = function (r) { const n = toNumber_(r[L.QTY]); return !isNaN(n) && n > 0; }; const qtyKosong = function (r) { return teks_(r[L.QTY]) === ''; }; const idRow = function (r) { return { nama: teks_(r[L.NAMA]), spec: teks_(r[L.SPEC]), type: teks_(r[L.TYPE]), brand: teks_(r[L.BRAND]), satuan: teks_(r[L.SATUAN]) }; }; const cariKode = function (id, kecuali) { const kunci = kunciIdentitas_(id); for (const k in katalog) { if (k !== kecuali && kunciIdentitas_(katalog[k]) === kunci) return k; } return ''; }; // 2. Baris log yang identitasnya baru diedit -> cocokkan ulang if (edit && edit.baris) { const rows = log[edit.log]; edit.baris.forEach(function (i) { const r = rows[i]; if (!r) return; const k = r[L.KODE]; const id = idRow(r); if (k === '' || id.nama === '') return; // tidak ada kode / nama dihapus -> biar diisi ulang if (katalog[k] && kunciIdentitas_(katalog[k]) === kunciIdentitas_(id)) return; // tidak berubah const lain = cariKode(id, k); if (lain) { r[L.KODE] = lain; return; } // ternyata barang lain yang sudah ada if (edit.log === 'masuk' && jumlahPakai_(log, k) === 1) { // Kode ini cuma dipakai baris ini -> anggap betulkan salah ketik: ganti nama di katalog const sat = id.satuan || (katalog[k] ? katalog[k].satuan : ''); katalog[k] = { nama: id.nama, spec: id.spec, type: id.type, brand: id.brand, satuan: sat }; return; } r[L.KODE] = ''; // barang berbeda -> masuk: dibuatkan kode baru; keluar: harus pilih kode }); } // 3. Kode di log yang tidak ada di katalog (mis. baris Master terhapus) -> pulihkan dari log [masuk, keluar].forEach(function (rows) { rows.forEach(function (r) { const k = r[L.KODE]; if (k !== '' && !katalog[k] && qtyValid(r) && teks_(r[L.NAMA]) !== '') katalog[k] = idRow(r); }); }); // 4. Baris ber-qty tanpa kode -> cari barang yang sama, atau (masuk) buat kode baru masuk.forEach(function (r) { if (r[L.KODE] !== '' || !qtyValid(r) || teks_(r[L.NAMA]) === '') return; let k = cariKode(idRow(r), ''); if (!k) { k = kodeBaru_(katalog, log); katalog[k] = idRow(r); } r[L.KODE] = k; }); keluar.forEach(function (r) { if (r[L.KODE] !== '' || !qtyValid(r) || teks_(r[L.NAMA]) === '') return; const k = cariKode(idRow(r), ''); if (k) r[L.KODE] = k; }); // 5. Jumlahkan const totMasuk = {}, totKeluar = {}, tglAkhir = {}, dipakai = {}; const tambah = function (rows, tot) { rows.forEach(function (r) { const k = r[L.KODE]; if (k === '' || !katalog[k] || !qtyValid(r)) return; tot[k] = (tot[k] || 0) + toNumber_(r[L.QTY]); dipakai[k] = true; const t = r[L.TGL] instanceof Date ? r[L.TGL] : (r[L.TGL] ? null : sekarang); if (t && (!tglAkhir[k] || t > tglAkhir[k])) tglAkhir[k] = t; }); }; tambah(masuk, totMasuk); tambah(keluar, totKeluar); const stok = function (k) { return bulatkan_((totMasuk[k] || 0) - (totKeluar[k] || 0)); }; // 6. Kode "yatim" (tidak punya satu pun transaksi ber-qty) dibersihkan dari baris log tanpa qty. // Contoh: Ctrl+Z qty barang baru -> kodenya ikut hilang, tidak meninggalkan jejak, // dan nomor kodenya dipakai lagi untuk barang baru berikutnya. // Aman: kode barang yang masih punya transaksi (Master/Arsip) tidak disentuh. [masuk, keluar].forEach(function (rows) { rows.forEach(function (r) { if (r[L.KODE] !== '' && !dipakai[r[L.KODE]] && qtyKosong(r)) r[L.KODE] = ''; }); }); // 7. Satuan: katalog kosong -> ambil dari log; log kosong -> ambil dari katalog [masuk, keluar].forEach(function (rows) { rows.forEach(function (r) { const k = r[L.KODE]; if (k !== '' && katalog[k] && katalog[k].satuan === '' && teks_(r[L.SATUAN]) !== '') katalog[k].satuan = teks_(r[L.SATUAN]); }); }); // 7b. Satuan yang baru diedit di log -> jadi satuan barang itu di katalog // (lalu ikut tersalin ke Master, Arsip, dan semua baris log di langkah 8-9) if (edit && edit.satuan) { edit.satuan.forEach(function (i) { const r = log[edit.log][i]; if (!r) return; const k = r[L.KODE], sat = teks_(r[L.SATUAN]); if (k !== '' && katalog[k] && sat !== '') katalog[k].satuan = sat; }); } // 8. Samakan identitas di log dengan katalog + tanggal otomatis + status const status = { masuk: [], keluar: [] }; ['masuk', 'keluar'].forEach(function (jenis) { log[jenis].forEach(function (r) { const k = r[L.KODE]; if (k !== '' && katalog[k]) { r[L.NAMA] = katalog[k].nama; r[L.SPEC] = katalog[k].spec; r[L.TYPE] = katalog[k].type; r[L.BRAND] = katalog[k].brand; if (katalog[k].satuan !== '') r[L.SATUAN] = katalog[k].satuan; // satuan sama di semua sheet } if (qtyValid(r) && teks_(r[L.TGL]) === '') r[L.TGL] = sekarang; const s = statusBaris_(jenis, r, k, katalog, qtyValid(r), qtyKosong(r), stok, totMasuk, totKeluar); r[L.STATUS] = s; status[jenis].push(s); }); }); // 9. Susun Master (stok != 0) dan Arsip (stok = 0), urut kode const kodeTerpakai = Object.keys(dipakai).sort(urutKode_); const master = [], arsip = []; kodeTerpakai.forEach(function (k) { const b = katalog[k], q = stok(k); if (q === 0) arsip.push([k, b.nama, b.spec, b.type, b.brand, 0, b.satuan, tglAkhir[k] || '']); else master.push([k, b.nama, b.spec, b.type, b.brand, q, b.satuan]); }); return { masuk: { rows: masuk, status: status.masuk }, keluar: { rows: keluar, status: status.keluar }, master: master, arsip: arsip, }; } function statusBaris_(jenis, r, k, katalog, valid, kosong, stok, totMasuk, totKeluar) { const isi = r.slice(0, L.STATUS).some(function (v) { return teks_(v) !== ''; }); if (!isi || kosong) return ''; if (!valid) return '⚠ Qty harus angka lebih dari 0'; if (jenis === 'masuk') { if (k === '') return '⚠ Isi Nama Barang (atau pilih Kode)'; if (!katalog[k]) return '⚠ Kode tidak dikenal'; return 'OK'; } if (k === '') return teks_(r[L.NAMA]) === '' ? '⚠ Pilih Kode barang' : '⚠ Barang tidak ditemukan — pilih Kode'; if (!katalog[k]) return '⚠ Kode tidak dikenal'; if (stok(k) < 0) return '⚠ Stok kurang: masuk ' + bulatkan_(totMasuk[k] || 0) + ', keluar ' + bulatkan_(totKeluar[k] || 0); return 'OK'; } // Berapa baris (di kedua log) yang memakai kode ini function jumlahPakai_(log, k) { let n = 0; log.masuk.concat(log.keluar).forEach(function (r) { if (r[L.KODE] === k) n++; }); return n; } // Kode berikutnya = terbesar di katalog & log + 1 function kodeBaru_(katalog, log) { let max = 0; const cek = function (k) { const n = toNumber_(k); if (!isNaN(n) && n > max) max = n; }; Object.keys(katalog).forEach(cek); log.masuk.concat(log.keluar).forEach(function (r) { cek(r[L.KODE]); }); return padKode_(max + 1); } function urutKode_(a, b) { const na = toNumber_(a), nb = toNumber_(b); if (!isNaN(na) && !isNaN(nb)) return na - nb; return a < b ? -1 : a > b ? 1 : 0; } // Kunci identitas: huruf kecil + SEMUA tanda baca & spasi dibuang. // "Elbow BS sch 40" = "elbow bs sch. 40". Beda angka tetap beda (sch 40 ≠ sch 45). function kunciIdentitas_(id) { return [id.nama, id.spec, id.type, id.brand].map(function (x) { return teks_(x).toLowerCase().replace(/[^a-z0-9]/g, ''); }).join('|'); } function padKode_(x) { const s = teks_(x); if (s === '') return ''; const n = toNumber_(s); if (isNaN(n)) return s; let p = String(Math.floor(n)); while (p.length < CONFIG.KODE_DIGIT) p = '0' + p; return p; } function teks_(x) { if (x === null || x === undefined) return ''; return String(x).trim(); } function toNumber_(x) { if (x === null || x === undefined || x === '') return NaN; if (typeof x === 'number') return x; const n = Number(String(x).trim().replace(',', '.')); return isNaN(n) ? NaN : n; } // Hindari 0.30000000000000004 function bulatkan_(x) { return Math.round(x * 1e6) / 1e6; }