sys_input; $date = isset($para["date"]) && $para["date"] != "" ? $para["date"] : date("Y-m-d"); if (!$this->isValidDate($date)) { throw new Exception("Format tanggal tidak valid. Gunakan YYYY-MM-DD."); } $branchCode = isset($para["branchCode"]) ? trim($para["branchCode"]) : ""; if ($branchCode == "") { throw new Exception("branchCode wajib diisi."); } $title = isset($para["title"]) ? trim($para["title"]) : ""; if ($title == "") { throw new Exception("title wajib diisi."); } $description = isset($para["description"]) ? trim($para["description"]) : ""; if ($description == "") { throw new Exception("description wajib diisi."); } $details = isset($para["details"]) && is_array($para["details"]) ? $para["details"] : []; if (count($details) == 0) { throw new Exception("details wajib diisi minimal 1 baris."); } $periodeID = isset($para["periodeID"]) && (int) $para["periodeID"] > 0 ? (int) $para["periodeID"] : $this->getPeriodeIDByDate($date); if ($periodeID <= 0) { throw new Exception("Periode jurnal tidak ditemukan untuk tanggal {$date}."); } $regionalID = isset($para["regionalID"]) && (int) $para["regionalID"] > 0 ? (int) $para["regionalID"] : $this->getRegionalIDByBranchCode($branchCode); if ($regionalID <= 0) { throw new Exception("regionalID wajib diisi atau harus bisa diturunkan dari branchCode."); } $branchCompanyID = isset($para["branchCompanyID"]) && (int) $para["branchCompanyID"] > 0 ? (int) $para["branchCompanyID"] : $this->getBranchCompanyIDByBranchCode($branchCode); if ($branchCompanyID <= 0) { throw new Exception("branchCompanyID wajib diisi atau harus bisa diturunkan dari branchCode."); } $jurnalTypeID = isset($para["jurnalTypeID"]) && (int) $para["jurnalTypeID"] > 0 ? (int) $para["jurnalTypeID"] : $this->getJurnalTypeID(isset($para["jurnalTypeCode"]) && $para["jurnalTypeCode"] != "" ? $para["jurnalTypeCode"] : "PAYMENT"); if ($jurnalTypeID <= 0) { throw new Exception("jurnalTypeID / jurnalTypeCode tidak valid."); } $userID = isset($para["userID"]) ? (int) $para["userID"] : 0; $jurnalNo = $this->generateJournalNumber(); if ($jurnalNo === false || $jurnalNo == "") { throw new Exception("Gagal generate nomor jurnal."); } // Validasi balance: total debit harus sama dengan total credit $totalDebit = 0; $totalCredit = 0; foreach ($details as $idx => $detail) { $debit = isset($detail["debit"]) ? (float) $detail["debit"] : 0; $credit = isset($detail["credit"]) ? (float) $detail["credit"] : 0; if ($debit < 0 || $credit < 0) { throw new Exception("Nilai debit/credit tidak boleh negatif pada detail ke-" . ($idx + 1)); } $totalDebit += $debit; $totalCredit += $credit; } if (round($totalDebit, 2) !== round($totalCredit, 2)) { throw new Exception("Total debit dan credit harus sama. Debit={$totalDebit}, Credit={$totalCredit}"); } $this->db->trans_begin(); // ----------------------------------------------------------------- // INSERT header jurnal ke tabel `jurnal` // ----------------------------------------------------------------- $sqlHeader = "INSERT INTO jurnal ( jurnalM_BranchCompanyID, JurnalS_RegionalID, jurnalM_BranchCode, jurnalperiodeID, jurnalNo, jurnalTitle, jurnalDescription, jurnalDate, jurnalJurnalTypeID, jurnalCreated, jurnalM_UserID ) VALUES (?,?,?,?,?,?,?,?,?,NOW(),?)"; $qryHeader = $this->db->query($sqlHeader, [ $branchCompanyID, $regionalID, $branchCode, $periodeID, $jurnalNo, $title, $description, $date, $jurnalTypeID, $userID ]); if (!$qryHeader) { $this->db->trans_rollback(); $this->sys_error_db("Gagal insert jurnal header."); exit; } $jurnalID = $this->db->insert_id(); $createdAddonCount = 0; // ----------------------------------------------------------------- // INSERT baris transaksi ke tabel `jurnal_tx` (per baris cicilan) // ----------------------------------------------------------------- foreach ($details as $idx => $detail) { $coaID = isset($detail["coaID"]) ? (int) $detail["coaID"] : 0; $descriptionTx = isset($detail["description"]) ? trim($detail["description"]) : ""; $debit = isset($detail["debit"]) ? (float) $detail["debit"] : 0; $credit = isset($detail["credit"]) ? (float) $detail["credit"] : 0; if ($coaID <= 0) { $this->db->trans_rollback(); $this->sys_error_db("coaID wajib diisi pada detail ke-" . ($idx + 1)); exit; } // Sisi debit = hutang cicilan (COA hutang) // Sisi kredit = pembayaran cicilan (COA kas/bank) $sqlTx = "INSERT INTO jurnal_tx ( jurnalTxJurnalID, jurnalTxCoaID, jurnalTxDescription, jurnalTxDebit, jurnalTxCredit, jurnalTxCreated, jurnalTxM_UserID ) VALUES (?,?,?,?,?,NOW(),?)"; $qryTx = $this->db->query($sqlTx, [ $jurnalID, $coaID, $descriptionTx, $debit, $credit, $userID ]); if (!$qryTx) { $this->db->trans_rollback(); $this->sys_error_db("Gagal insert jurnal_tx pada detail ke-" . ($idx + 1)); exit; } $jurnalTxID = $this->db->insert_id(); // ----------------------------------------------------------------- // INSERT data tambahan ke tabel `jurnal_addon` (jika ada) // Diisi jika ada addonValue (misal nomor PI) atau mItemID (ID aset) // ----------------------------------------------------------------- $addonValue = isset($detail["addonValue"]) ? trim($detail["addonValue"]) : ""; $mItemID = isset($detail["mItemID"]) ? (int) $detail["mItemID"] : 0; $addonCode = isset($detail["addonCode"]) && $detail["addonCode"] != "" ? trim($detail["addonCode"]) : "PICICIL"; if ($addonValue != "" || $mItemID > 0) { $sqlAddon = "INSERT INTO jurnal_addon ( jurnalAddOnJurnalID, jurnalAddOnJurnalTxID, jurnalAddOnCode, jurnalAddOnValue, jurnalAddOnM_ItemID, jurnalAddOnCreated, jurnalAddOnCreatedUserID ) VALUES (?,?,?,?,?,NOW(),?)"; $qryAddon = $this->db->query($sqlAddon, [ $jurnalID, $jurnalTxID, $addonCode, $addonValue, $mItemID > 0 ? $mItemID : null, $userID ]); if (!$qryAddon) { $this->db->trans_rollback(); $this->sys_error_db("Gagal insert jurnal_addon pada detail ke-" . ($idx + 1)); exit; } $createdAddonCount++; } } if ($this->db->trans_status() === false) { $this->db->trans_rollback(); $this->sys_error_db("Transaksi jurnal gagal."); exit; } $this->db->trans_commit(); $this->sys_ok([ "jurnalID" => $jurnalID, "jurnalNo" => $jurnalNo, "detailCount" => count($details), "addonCount" => $createdAddonCount ]); } catch (Exception $exc) { $this->sys_error($exc->getMessage()); } } /** * CreatePiInstallmentJournal — Alias dari CreateJournal. * * Endpoint alternatif dengan nama yang lebih spesifik untuk konteks * pembuatan jurnal PI cicilan order aset. Memanggil CreateJournal secara langsung. * * @see CreateJournal() */ public function CreatePiInstallmentJournal() { $this->CreateJournal(); } /** * ListEligiblePurchaseOrderAssetContracts — Menampilkan daftar kontrak aset * yang memenuhi syarat untuk dibuatkan jurnal PI cicilan pada periode tertentu. * * Syarat kontrak eligible: * - Kontrak aktif dan berstatus "belum lunas" * - Nilai cicilan > 0 * - Jumlah cicilan terbayar < total cicilan * - Tanggal kontrak masuk dalam rentang startDate–endDate * - PO sudah berstatus Approved * - Barang sudah diterima (receive_order_po sudah confirmed) * * Endpoint ini digunakan untuk preview/verifikasi sebelum proses generate jurnal * dijalankan secara massal oleh CRON. * * Parameter (GET/POST): * - startDate : Awal periode (YYYY-MM-DD). Default: tanggal 1 bulan ini. * - endDate : Akhir periode (YYYY-MM-DD). Default: tanggal akhir bulan ini. * * @return JSON { startDate, endDate, total, records[] } */ public function ListEligiblePurchaseOrderAssetContracts() { try { $para = $this->sys_input; $startDate = isset($para["startDate"]) && $para["startDate"] != "" ? $para["startDate"] : date("Y-m-01"); $endDate = isset($para["endDate"]) && $para["endDate"] != "" ? $para["endDate"] : date("Y-m-t"); if (!$this->isValidDate($startDate) || !$this->isValidDate($endDate)) { throw new Exception("Format tanggal tidak valid. Gunakan YYYY-MM-DD."); } if (strtotime($startDate) > strtotime($endDate)) { throw new Exception("startDate tidak boleh lebih besar dari endDate"); } $records = $this->getEligiblePurchaseOrderAssetContracts($startDate, $endDate); $this->sys_ok([ "startDate" => $startDate, "endDate" => $endDate, "total" => count($records), "records" => $records ]); } catch (Exception $exc) { $this->sys_error($exc->getMessage()); } } // ========================================================================= // FUNGSI PRIVATE / HELPER // ========================================================================= /** * generateJournalNumber — Generate nomor jurnal otomatis. * * Memanggil stored function MySQL fn_numbering('J') untuk mendapatkan * nomor jurnal berikutnya sesuai urutan penomoran yang berlaku. * * @return string|false Nomor jurnal (misal: "J/2025/07/0001"), atau false jika gagal. */ private function generateJournalNumber() { $qry = $this->db->query("SELECT fn_numbering('J') AS JNumber"); if (!$qry || $qry->num_rows() == 0) { return false; } return $qry->row_array()["JNumber"]; } /** * getPeriodeIDByDate — Ambil ID periode akuntansi berdasarkan tanggal transaksi. * * Periode diambil dari tabel `periode` yang aktif dan mencakup tanggal yang diberikan. * Digunakan untuk mengisi field jurnalperiodeID pada header jurnal. * * @param string $date Tanggal transaksi (format YYYY-MM-DD) * @return int periodeID yang sesuai, atau 0 jika tidak ditemukan */ private function getPeriodeIDByDate($date) { $sql = "SELECT periodeID FROM periode WHERE periodeIsActive = 'Y' AND DATE(?) BETWEEN periodeStartDate AND periodeEndDate LIMIT 1"; $qry = $this->db->query($sql, [$date]); if (!$qry || $qry->num_rows() == 0) { return 0; } return (int) $qry->row_array()["periodeID"]; } /** * getBranchCompanyIDByBranchCode — Ambil ID branch company berdasarkan kode cabang. * * Data diambil dari tabel `m_branch_companydetail` yang aktif. * Digunakan untuk mengisi field jurnalM_BranchCompanyID pada header jurnal. * * @param string $branchCode Kode cabang (misal: "JKT-01") * @return int branchCompanyID yang sesuai, atau 0 jika tidak ditemukan */ private function getBranchCompanyIDByBranchCode($branchCode) { $sql = "SELECT M_BranchCompanyDetailM_BranchCompanyID AS branchCompanyID FROM m_branch_companydetail WHERE M_BranchCompanyDetailM_BranchCode = ? AND M_BranchCompanyDetailIsActive = 'Y' LIMIT 1"; $qry = $this->db->query($sql, [$branchCode]); if (!$qry || $qry->num_rows() == 0) { return 0; } return (int) $qry->row_array()["branchCompanyID"]; } /** * getRegionalIDByBranchCode — Ambil ID regional berdasarkan kode cabang. * * Data diambil dari tabel `m_branch` yang aktif. * Digunakan untuk mengisi field JurnalS_RegionalID pada header jurnal. * * @param string $branchCode Kode cabang (misal: "JKT-01") * @return int regionalID yang sesuai, atau 0 jika tidak ditemukan */ private function getRegionalIDByBranchCode($branchCode) { $sql = "SELECT M_BranchS_RegionalID AS regionalID FROM m_branch WHERE M_BranchCode = ? AND M_BranchIsActive = 'Y' LIMIT 1"; $qry = $this->db->query($sql, [$branchCode]); if (!$qry || $qry->num_rows() == 0) { return 0; } return (int) $qry->row_array()["regionalID"]; } /** * getJurnalTypeID — Ambil ID tipe jurnal berdasarkan kode tipe. * * Data diambil dari tabel `jurnal_type` yang aktif. * Untuk PI cicilan order aset, kode yang digunakan adalah "PAYMENT". * * @param string $jurnalTypeCode Kode tipe jurnal (misal: "PAYMENT") * @return int JurnalTypeID yang sesuai, atau 0 jika tidak ditemukan */ private function getJurnalTypeID($jurnalTypeCode) { $sql = "SELECT JurnalTypeID FROM jurnal_type WHERE JurnalTypeCode = ? AND JurnalTypeIsActive = 'Y' LIMIT 1"; $qry = $this->db->query($sql, [$jurnalTypeCode]); if (!$qry || $qry->num_rows() == 0) { return 0; } return (int) $qry->row_array()["JurnalTypeID"]; } /** * getEligiblePurchaseOrderAssetContracts — Ambil kontrak aset yang layak dibuatkan jurnal. * * Query ini mengambil semua kontrak dari `purchase_order_asset_contract` yang memenuhi * syarat berikut: * - Kontrak aktif, status "belum lunas", nilai cicilan > 0 * - Cicilan terbayar masih kurang dari total cicilan * - Tanggal kontrak masuk dalam rentang periode yang diberikan * - Tanggal jatuh tempo cicilan sudah lewat atau sama dengan hari ini * - PO sudah Approved dan aktif * - Barang sudah diterima dan receive order sudah confirmed * * Data yang dikembalikan mencakup: * - Detail kontrak (ID, nama, tanggal, nilai cicilan) * - Data PO (nomor PO, supplier, payment term, warehouse) * - Data cabang (kode cabang, nama, regionalID, branchCompanyID) * - Data item PO pertama (sebagai referensi baris jurnal) * * @param string $startDate Awal periode filter (YYYY-MM-DD) * @param string $endDate Akhir periode filter (YYYY-MM-DD) * @return array Array of row kontrak yang eligible */ private function getEligiblePurchaseOrderAssetContracts($startDate, $endDate) { $sql = "SELECT c.PurchaseOrderAssetContractID, c.PurchaseOrderAssetContractPurchaseOrderID, c.PurchaseOrderAssetContractName, c.PurchaseOrderAssetContractStartDate, c.PurchaseOrderAssetContractEndDate, c.PurchaseOrderAssetContractInstallmentNumber, c.PurchaseOrderAssetContractInstallmentPaid, c.PurchaseOrderAssetContractInstallmentDate, c.PurchaseOrderAssetContractInstallmentPayAmount, c.PurchaseOrderAssetContractReceiveOrderPoID, po.PurchaseOrderID, po.PurchaseOrderNumber, po.PurchaseOrderSupplierID, po.PurchaseOrderPaymentTerm, po.PurchaseOrderWarehouseType, po.PurchaseOrderWarehouseID, wh.WarehouseCode, wh.WarehouseName, wh.WarehouseType, wh.WarehouseS_RegionalID AS WarehouseS_RegionalID, wh.WarehouseM_BranchID AS WarehouseM_BranchID, ro.ReceiveOrderPoID, ro.ReceiveOrderPoConfirmed, mi.M_ItemID AS ItemID, mi.M_ItemCode AS ItemCode, mi.M_ItemDesc AS ItemDesc, mi.M_ItemFa_ClassID, fc.Fa_ClassID AS ItemFaClassID, fc.Fa_ClassName AS ItemFaClassName, fc.Fa_ClassCoaID AS ItemCoaID, fc.Fa_ClassCoaAccountNo AS ItemCoaAccountNo, fc.Fa_ClassCoaDesc AS ItemCoaDesc, m_branch.M_BranchCode, m_branch.M_BranchName, m_branch.M_BranchS_RegionalID, m_branch_companydetail.M_BranchCompanyDetailM_BranchCompanyID AS M_BranchCompanyID, ps.PurchaseOrderSummaryID, ps.PurchaseOrderSummaryItemID, ps.PurchaseOrderSummaryItemUnitID FROM purchase_order_asset_contract c -- Pastikan PO sudah Approved dan aktif JOIN purchase_order po ON po.PurchaseOrderID = c.PurchaseOrderAssetContractPurchaseOrderID AND po.PurchaseOrderIsActive = 'Y' AND po.PurchaseOrderStatus = 'Approved' -- Data warehouse/gudang tujuan PO LEFT JOIN warehouse wh ON wh.WarehouseID = po.PurchaseOrderWarehouseID -- Pastikan barang sudah diterima (receive order confirmed) JOIN ( SELECT rd.ReceiveOrderPoDetailPurchaseOrderID, MIN(ro0.ReceiveOrderPoID) AS ReceiveOrderPoID, MAX(ro0.ReceiveOrderPoConfirmed) AS ReceiveOrderPoConfirmed FROM receive_order_po ro0 JOIN receive_order_po_detail rd ON rd.ReceiveOrderPoDetailReceiveOrderPoID = ro0.ReceiveOrderPoID AND rd.ReceiveOrderPoDetailIsActive = 'Y' WHERE ro0.ReceiveOrderPoIsActive = 'Y' AND ro0.ReceiveOrderPoConfirmed = 'Y' GROUP BY rd.ReceiveOrderPoDetailPurchaseOrderID ) ro ON ro.ReceiveOrderPoDetailPurchaseOrderID = po.PurchaseOrderID -- Data cabang dari warehouse LEFT JOIN m_branch ON m_branch.M_BranchID = wh.WarehouseM_BranchID AND m_branch.M_BranchIsActive = 'Y' -- Data branch company untuk header jurnal LEFT JOIN m_branch_companydetail ON m_branch_companydetail.M_BranchCompanyDetailM_BranchCode = m_branch.M_BranchCode AND m_branch_companydetail.M_BranchCompanyDetailIsActive = 'Y' -- Ambil 1 item PO pertama sebagai referensi baris jurnal LEFT JOIN ( SELECT ps0.* FROM purchase_order_summary ps0 JOIN ( SELECT PurchaseOrderSummaryPurchaseOrderID, MIN(PurchaseOrderSummaryID) AS PurchaseOrderSummaryID FROM purchase_order_summary WHERE PurchaseOrderSummaryIsActive = 'Y' GROUP BY PurchaseOrderSummaryPurchaseOrderID ) psx ON psx.PurchaseOrderSummaryID = ps0.PurchaseOrderSummaryID ) ps ON ps.PurchaseOrderSummaryPurchaseOrderID = po.PurchaseOrderID -- COA jurnal diambil dari item -> fa_class LEFT JOIN m_item mi ON mi.M_ItemID = ps.PurchaseOrderSummaryItemID AND mi.M_ItemIsActive = 'Y' LEFT JOIN fa_class fc ON fc.Fa_ClassID = mi.M_ItemFa_ClassID AND fc.Fa_ClassIsActive = 'Y' WHERE c.PurchaseOrderAssetContractIsActive = 'Y' AND c.PurchaseOrderAssetContractStatus = 'belum lunas' -- Hanya kontrak yang ada nilai cicilannya AND IFNULL(c.PurchaseOrderAssetContractInstallmentPayAmount, 0) > 0 -- Hanya kontrak yang belum selesai seluruh cicilannya AND IFNULL(c.PurchaseOrderAssetContractInstallmentPaid, 0) < IFNULL(c.PurchaseOrderAssetContractInstallmentNumber, 0) -- Kontrak sudah mulai sebelum atau pada akhir periode AND DATE(c.PurchaseOrderAssetContractStartDate) <= DATE(?) -- Kontrak belum berakhir (atau tidak ada tanggal akhir) AND ( c.PurchaseOrderAssetContractEndDate IS NULL OR DATE(c.PurchaseOrderAssetContractEndDate) >= DATE(?) ) -- Tanggal jatuh tempo cicilan sudah tiba AND IFNULL(c.PurchaseOrderAssetContractInstallmentDate, 1) <= ? ORDER BY c.PurchaseOrderAssetContractID ASC"; $qry = $this->db->query($sql, [$endDate, $startDate, (int) date("d", strtotime($endDate))]); if (!$qry) { $this->sys_error_db("Gagal mengambil data purchase_order_asset_contract."); exit; } return $qry->result_array(); } /** * isValidDate — Validasi format tanggal YYYY-MM-DD. * * @param string $tanggal String tanggal yang akan divalidasi * @return bool true jika format valid, false jika tidak */ private function isValidDate($tanggal) { $d = DateTime::createFromFormat("Y-m-d", $tanggal); return $d && $d->format("Y-m-d") === $tanggal; } }