0 * 3. Jumlah cicilan terbayar < total cicilan * 4. Tanggal kontrak masuk dalam bulan yang dituju * 5. RO sudah confirmed * 6. PO sudah berstatus Approved */ public function GenerateMonthlyInvoices() { try { $para = $this->sys_input; // Ambil userID dari token JWT (sys_user). Fallback ke 0 jika CRON berjalan tanpa token. $userID = !empty($this->sys_user["M_UserID"]) ? (int) $this->sys_user["M_UserID"] : 0; $user = $this->getCronUser($userID); $startDate = isset($para["startDate"]) && $para["startDate"] != "" ? $para["startDate"] : (isset($para["date"]) && $para["date"] != "" ? date("Y-m-01", strtotime($para["date"])) : date("Y-m-01")); $endDate = isset($para["endDate"]) && $para["endDate"] != "" ? $para["endDate"] : (isset($para["date"]) && $para["date"] != "" ? date("Y-m-t", strtotime($para["date"])) : date("Y-m-t")); // Validasi format tanggal if (!$this->isValidDate($startDate) || !$this->isValidDate($endDate)) { throw new Exception("Format tanggal tidak valid. Gunakan format YYYY-MM-DD, contoh: 2025-07-01"); } if (strtotime($startDate) > strtotime($endDate)) { throw new Exception("startDate tidak boleh lebih besar dari endDate"); } // Tentukan rentang bulan berdasarkan tanggal acuan $monthStart = $startDate; $monthEnd = $endDate; $dayOfMonth = (int) date("d", strtotime($endDate)); // ------------------------------------------------------------------------- // Ambil semua kontrak cicilan yang memenuhi syarat pada bulan ini // ------------------------------------------------------------------------- $sqlKontrak = "SELECT c.PurchaseOrderAssetContractID, c.PurchaseOrderAssetContractPurchaseOrderID, ro.ReceiveOrderPoID AS PurchaseOrderAssetContractReceiveOrderPoID, c.PurchaseOrderAssetContractName, c.PurchaseOrderAssetContractStartDate, c.PurchaseOrderAssetContractEndDate, c.PurchaseOrderAssetContractInstallmentNumber, c.PurchaseOrderAssetContractInstallmentPaid, c.PurchaseOrderAssetContractInstallmentDate, c.PurchaseOrderAssetContractInstallmentPayAmount, po.PurchaseOrderID, po.PurchaseOrderNumber, po.PurchaseOrderSupplierID, po.PurchaseOrderPaymentTerm, po.PurchaseOrderWarehouseType, po.PurchaseOrderWarehouseID, ro.ReceiveOrderPoConfirmed, 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' 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 -- Ambil 1 item PO pertama sebagai referensi baris detail PI 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 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 bulan ini 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 melewati atau sama dengan hari ini AND IFNULL(c.PurchaseOrderAssetContractInstallmentDate, 1) <= ?"; $params = [$monthEnd, $monthStart, $dayOfMonth]; $qryKontrak = $this->db->query($sqlKontrak, $params); if (!$qryKontrak) { $this->sys_error_db("Gagal mengambil daftar kontrak cicilan dari database."); exit; } $berhasil = []; // PI yang berhasil dibuat $dilewati = []; // PI yang dilewati beserta alasannya // ------------------------------------------------------------------------- // Proses tiap kontrak satu per satu // ------------------------------------------------------------------------- $kontraks = $qryKontrak->result_array(); foreach ($kontraks as $kontrak) { // Lewati jika item/satuan PO tidak ditemukan (tidak bisa buat baris detail) if (empty($kontrak["PurchaseOrderSummaryItemID"]) || empty($kontrak["PurchaseOrderSummaryItemUnitID"])) { $dilewati[] = [ "kontrakID" => $kontrak["PurchaseOrderAssetContractID"], "purchaseOrderID" => $kontrak["PurchaseOrderID"], "alasan" => "Item atau satuan pada Purchase Order tidak ditemukan, tidak bisa membuat baris detail PI." ]; continue; } // ------------------------------------------------------- // Cek apakah PI cicilan bulan ini sudah pernah dibuat // ------------------------------------------------------- $sqlCekDuplikat = "SELECT SupplierInvoiceID, SupplierInvoiceNumber FROM supplier_invoice WHERE SupplierInvoiceIsActive = 'Y' AND SupplierInvoiceDate >= DATE(?) AND SupplierInvoiceDate <= DATE(?) AND SupplierInvoiceStatus = 'Draft' AND ( SupplierInvoiceReceiveOrderPoID = ? OR EXISTS ( SELECT 1 FROM supplier_invoice_detail sid WHERE sid.SupplierInvoiceDetailSupplierInvoiceID = SupplierInvoiceID AND sid.SupplierInvoiceDetailReceiveOrderPoID = ? AND sid.SupplierInvoiceDetailIsActive = 'Y' ) ) LIMIT 1"; $qryCekDuplikat = $this->db->query($sqlCekDuplikat, [ $monthStart, $monthEnd, $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"] ]); if (!$qryCekDuplikat) { $this->sys_error_db("Gagal memeriksa duplikasi PI cicilan untuk kontrak ID " . $kontrak["PurchaseOrderAssetContractID"] . "."); exit; } if ($qryCekDuplikat->num_rows() > 0) { $piExisting = $qryCekDuplikat->row_array(); $dilewati[] = [ "kontrakID" => $kontrak["PurchaseOrderAssetContractID"], "purchaseOrderID" => $kontrak["PurchaseOrderID"], "receiveOrderPoID" => $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], "supplierInvoiceID" => $piExisting["SupplierInvoiceID"], "nomorInvoice" => $piExisting["SupplierInvoiceNumber"], "alasan" => "PI cicilan untuk bulan ini sudah dibuat sebelumnya, tidak perlu dibuat ulang." ]; continue; } // Generate nomor PI otomatis via stored function fn_penomoran $nomorPI = $this->generateNomorPI($kontrak, $user); $jumlahCicilan = (float) $kontrak["PurchaseOrderAssetContractInstallmentPayAmount"]; $paymentTerm = isset($kontrak["PurchaseOrderPaymentTerm"]) && $kontrak["PurchaseOrderPaymentTerm"] !== null ? (int) $kontrak["PurchaseOrderPaymentTerm"] : 0; $tanggalJatuhTempo = $this->hitungJatuhTempo($endDate, $paymentTerm); $catatan = "PI Cicilan Otomatis — Kontrak Aset ID " . $kontrak["PurchaseOrderAssetContractID"] . " periode " . date("Y-m", strtotime($startDate)); // ------------------------------------------------------- // INSERT header + detail Purchase Invoice (supplier_invoice & supplier_invoice_detail) // ------------------------------------------------------- $deskripsi = $kontrak["PurchaseOrderAssetContractName"] != "" ? $kontrak["PurchaseOrderAssetContractName"] : "Cicilan Kontrak Aset ID " . $kontrak["PurchaseOrderAssetContractID"]; $deskripsi .= " periode " . date("Y-m", strtotime($startDate)); $payloadInsert = [ "nomorPI" => $nomorPI, "tanggalPI" => $endDate, "tanggalJatuhTempo" => $tanggalJatuhTempo, "jumlahCicilan" => $jumlahCicilan, "catatan" => $catatan, "deskripsi" => $deskripsi, "userID" => $userID, "purchaseOrderAssetContractID" => $kontrak["PurchaseOrderAssetContractID"], "purchaseOrderID" => $kontrak["PurchaseOrderID"], "receiveOrderPoID" => $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], "supplierID" => $kontrak["PurchaseOrderSupplierID"], "purchaseOrderSummaryID" => $kontrak["PurchaseOrderSummaryID"], "purchaseOrderSummaryItemID" => $kontrak["PurchaseOrderSummaryItemID"], "purchaseOrderSummaryItemUnitID" => $kontrak["PurchaseOrderSummaryItemUnitID"] ]; $hasilInsert = $this->curlInsertSupplierInvoice($payloadInsert); if ($hasilInsert === false) { $this->sys_error_db("Gagal menyimpan Purchase Invoice cicilan untuk kontrak ID " . $kontrak["PurchaseOrderAssetContractID"] . "."); exit; } if (!empty($hasilInsert["duplicate"])) { $dilewati[] = [ "kontrakID" => $kontrak["PurchaseOrderAssetContractID"], "purchaseOrderID" => $kontrak["PurchaseOrderID"], "receiveOrderPoID" => $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], "supplierInvoiceID" => $hasilInsert["supplierInvoiceID"], "nomorInvoice" => $hasilInsert["supplierInvoiceNumber"], "alasan" => "PI cicilan untuk bulan ini sudah dibuat sebelumnya, tidak perlu dibuat ulang." ]; continue; } $supplierInvoiceID = $hasilInsert["supplierInvoiceID"]; // ------------------------------------------------------- // UPDATE jumlah cicilan terbayar pada kontrak // ------------------------------------------------------- $this->db->trans_begin(); $cicilanTerbayarBaru = ((int) $kontrak["PurchaseOrderAssetContractInstallmentPaid"]) + 1; $statusKontrakBaru = $cicilanTerbayarBaru >= (int) $kontrak["PurchaseOrderAssetContractInstallmentNumber"] ? "lunas" : "belum lunas"; $sqlUpdateKontrak = "UPDATE purchase_order_asset_contract SET PurchaseOrderAssetContractReceiveOrderPoID = ?, PurchaseOrderAssetContractInstallmentPaid = ?, PurchaseOrderAssetContractStatus = ?, PurchaseOrderAssetContractLastUpdated = NOW() WHERE PurchaseOrderAssetContractID = ? AND PurchaseOrderAssetContractIsActive = 'Y'"; $qryUpdateKontrak = $this->db->query($sqlUpdateKontrak, [ $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], $cicilanTerbayarBaru, $statusKontrakBaru, $kontrak["PurchaseOrderAssetContractID"] ]); if (!$qryUpdateKontrak) { $this->db->trans_rollback(); $this->sys_error_db("Gagal memperbarui data cicilan terbayar pada kontrak ID " . $kontrak["PurchaseOrderAssetContractID"] . "."); exit; } // Pastikan tidak ada error di dalam transaksi sebelum commit if ($this->db->trans_status() === false) { $this->db->trans_rollback(); $this->sys_error_db("Transaksi database gagal saat memproses kontrak ID " . $kontrak["PurchaseOrderAssetContractID"] . ". Semua perubahan dibatalkan."); exit; } $this->db->trans_commit(); // Catat PI yang berhasil dibuat $berhasil[] = [ "kontrakID" => $kontrak["PurchaseOrderAssetContractID"], "purchaseOrderID" => $kontrak["PurchaseOrderID"], "supplierInvoiceID" => $supplierInvoiceID, "nomorInvoice" => $nomorPI, "jumlahCicilan" => $jumlahCicilan ]; } // ------------------------------------------------------- // Response sukses — ringkasan hasil proses // ------------------------------------------------------- $this->sys_ok([ "startDate" => $startDate, "endDate" => $endDate, "periodeAwal" => $monthStart, "periodeAkhir" => $monthEnd, "totalDibuat" => count($berhasil), "totalDilewati" => count($dilewati), "daftarDibuat" => $berhasil, "daftarDilewati" => $dilewati ]); } catch (Exception $exc) { if ($this->db->trans_status() === false) { $this->db->trans_rollback(); } $this->sys_error($exc->getMessage()); } } /** * Kirim payload insert supplier_invoice ke controller terpisah via cURL. * * @param array $payload * @return array|false */ private function curlInsertSupplierInvoice($payload) { $endpoint = rtrim($this->baseUrl, "/") . "/mockup/scheduler/PurchaseInvoiceInstallmentInsert/InsertSupplierInvoice"; $ch = curl_init($endpoint); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_POST => true, CURLOPT_HTTPHEADER => [ "Content-Type: application/json", "Accept: application/json" ], CURLOPT_POSTFIELDS => json_encode($payload), CURLOPT_CONNECTTIMEOUT => 15, CURLOPT_TIMEOUT => 120 ]); $response = curl_exec($ch); if (curl_errno($ch)) { $pesanError = curl_error($ch); curl_close($ch); $this->sys_error("Gagal menghubungi endpoint insert supplier_invoice. Detail: " . $pesanError); return false; } $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode < 200 || $httpCode >= 300) { $this->sys_error("Endpoint insert supplier_invoice mengembalikan HTTP " . $httpCode . "."); return false; } $decoded = json_decode($response, true); if (!is_array($decoded)) { $this->sys_error("Response insert supplier_invoice tidak valid JSON."); return false; } if (!isset($decoded["status"]) || strtoupper($decoded["status"]) !== "OK") { $pesan = isset($decoded["message"]) ? $decoded["message"] : "Insert supplier_invoice gagal."; $this->sys_error($pesan); return false; } return isset($decoded["data"]) && is_array($decoded["data"]) ? $decoded["data"] : $decoded; } public function ListEligibleContracts() { try { $para = $this->sys_input; $startDate = isset($para["startDate"]) && $para["startDate"] != "" ? $para["startDate"] : (isset($para["date"]) && $para["date"] != "" ? date("Y-m-01", strtotime($para["date"])) : date("Y-m-01")); $endDate = isset($para["endDate"]) && $para["endDate"] != "" ? $para["endDate"] : (isset($para["date"]) && $para["date"] != "" ? date("Y-m-t", strtotime($para["date"])) : date("Y-m-t")); if (!$this->isValidDate($startDate) || !$this->isValidDate($endDate)) { throw new Exception("Format tanggal tidak valid. Gunakan format YYYY-MM-DD, contoh: 2025-07-01"); } if (strtotime($startDate) > strtotime($endDate)) { throw new Exception("startDate tidak boleh lebih besar dari endDate"); } $monthStart = $startDate; $monthEnd = $endDate; $dayOfMonth = (int) date("d", strtotime($endDate)); $sqlKontrak = "SELECT c.PurchaseOrderAssetContractID, c.PurchaseOrderAssetContractPurchaseOrderID, ro.ReceiveOrderPoID AS PurchaseOrderAssetContractReceiveOrderPoID, c.PurchaseOrderAssetContractName, c.PurchaseOrderAssetContractStartDate, c.PurchaseOrderAssetContractEndDate, c.PurchaseOrderAssetContractInstallmentNumber, c.PurchaseOrderAssetContractInstallmentPaid, c.PurchaseOrderAssetContractInstallmentDate, c.PurchaseOrderAssetContractInstallmentPayAmount, po.PurchaseOrderID, po.PurchaseOrderNumber, po.PurchaseOrderSupplierID, po.PurchaseOrderPaymentTerm, po.PurchaseOrderWarehouseType, po.PurchaseOrderWarehouseID, ro.ReceiveOrderPoConfirmed, ps.PurchaseOrderSummaryID, ps.PurchaseOrderSummaryItemID, ps.PurchaseOrderSummaryItemUnitID FROM purchase_order_asset_contract c JOIN purchase_order po ON po.PurchaseOrderID = c.PurchaseOrderAssetContractPurchaseOrderID AND po.PurchaseOrderIsActive = 'Y' AND po.PurchaseOrderStatus = 'Approved' 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 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 WHERE c.PurchaseOrderAssetContractIsActive = 'Y' AND c.PurchaseOrderAssetContractStatus = 'belum lunas' AND IFNULL(c.PurchaseOrderAssetContractInstallmentPayAmount, 0) > 0 AND IFNULL(c.PurchaseOrderAssetContractInstallmentPaid, 0) < IFNULL(c.PurchaseOrderAssetContractInstallmentNumber, 0) AND DATE(c.PurchaseOrderAssetContractStartDate) <= DATE(?) AND ( c.PurchaseOrderAssetContractEndDate IS NULL OR DATE(c.PurchaseOrderAssetContractEndDate) >= DATE(?) ) AND IFNULL(c.PurchaseOrderAssetContractInstallmentDate, 1) <= ? ORDER BY c.PurchaseOrderAssetContractID ASC"; $qryKontrak = $this->db->query($sqlKontrak, [$monthEnd, $monthStart, $dayOfMonth]); if (!$qryKontrak) { $this->sys_error_db("Gagal mengambil daftar kontrak cicilan eligible."); exit; } $this->sys_ok([ "startDate" => $startDate, "endDate" => $endDate, "periodeAwal" => $monthStart, "periodeAkhir" => $monthEnd, "total" => $qryKontrak->num_rows(), "records" => $qryKontrak->result_array() ]); } catch (Exception $exc) { $this->sys_error($exc->getMessage()); } } /** * CurlGenerateMonthlyInvoices * * Wrapper untuk memanggil GenerateMonthlyInvoices melalui HTTP request (cURL). * Cocok digunakan oleh CRON eksternal yang tidak bisa memanggil function PHP langsung. * * Parameter (POST JSON): * - baseUrl : Base URL server tujuan. Default: URL server ini sendiri. * - startDate : Tanggal awal periode (YYYY-MM-DD). Default: awal bulan ini. * - endDate : Tanggal akhir periode (YYYY-MM-DD). Default: akhir bulan ini. * - userID : ID user yang menjalankan. Default: 0. */ // ========================================================================= // FUNGSI PRIVATE / HELPER // ========================================================================= /** * Generate nomor Purchase Invoice default untuk proses sementara. * * @param array $kontrak Data baris kontrak dari query * @param array $user Data user CRON * @return string Nomor PI yang dihasilkan */ private function generateNomorPI($kontrak, $user) { $contractID = isset($kontrak["PurchaseOrderAssetContractID"]) ? (int) $kontrak["PurchaseOrderAssetContractID"] : 0; $poID = isset($kontrak["PurchaseOrderID"]) ? (int) $kontrak["PurchaseOrderID"] : 0; $userID = isset($user["M_UserID"]) ? (int) $user["M_UserID"] : 0; return sprintf( "PI-INS-%s-%06d-%05d-%05d", date("YmdHis"), $contractID, $poID, $userID ); } /** * Ambil data user berdasarkan userID untuk keperluan CRON. * Jika user tidak ditemukan, kembalikan data default (tanpa branch/regional). * * @param int $userID * @return array */ private function getCronUser($userID) { $sql = "SELECT M_UserID, M_UserM_BranchID AS M_BranchID, M_UserS_RegionalID AS S_RegionalID FROM m_user WHERE M_UserID = ? LIMIT 1"; $qry = $this->db->query($sql, [$userID]); if ($qry && $qry->num_rows() > 0) { $user = $qry->row_array(); $user["loginLevel"] = "branch"; return $user; } // User tidak ditemukan, gunakan data kosong agar proses tetap berjalan return [ "M_UserID" => $userID, "M_BranchID" => 0, "S_RegionalID" => 0, "loginLevel" => "branch" ]; } /** * Hitung tanggal jatuh tempo berdasarkan tanggal acuan dan payment term (dalam hari). * * @param string $tanggal Format YYYY-MM-DD * @param int $term Jumlah hari payment term * @return string Tanggal jatuh tempo (YYYY-MM-DD) */ private function hitungJatuhTempo($tanggal, $term) { $hari = is_numeric($term) ? (int) $term : 0; if ($hari < 0) { $hari = 0; } return date("Y-m-d", strtotime($tanggal . " +" . $hari . " days")); } /** * Validasi apakah string adalah tanggal yang valid dengan format YYYY-MM-DD. * * @param string $tanggal * @return bool */ private function isValidDate($tanggal) { $d = DateTime::createFromFormat("Y-m-d", $tanggal); return $d && $d->format("Y-m-d") === $tanggal; } }