diff --git a/application/controllers/mockup/scheduler/PurchaseInvoiceInstallment.http b/application/controllers/mockup/scheduler/PurchaseInvoiceInstallment.http new file mode 100644 index 0000000..20cdd4d --- /dev/null +++ b/application/controllers/mockup/scheduler/PurchaseInvoiceInstallment.http @@ -0,0 +1,56 @@ +@host = accone.aplikasi.web.id/one-api/mockup + +### Generate PI cicilan langsung +POST https://{{host}}/scheduler/PurchaseInvoiceInstallment/GenerateMonthlyInvoices +Content-Type: application/json + +{ + "startDate": "2026-07-01", + "endDate": "2026-07-31", + "dryRun": "Y" +} + +### List kontrak eligible yang belum diproses +POST https://{{host}}/scheduler/PurchaseInvoiceInstallment/ListEligibleContracts +Content-Type: application/json + +{ + "startDate": "2026-07-01", + "endDate": "2026-07-31" +} + +### Generate PI cicilan untuk 1 kontrak +POST https://{{host}}/scheduler/PurchaseInvoiceInstallment/GenerateMonthlyInvoices +Content-Type: application/json + +{ + "startDate": "2026-07-01", + "endDate": "2026-07-31", + "contractID": 123, + "dryRun": "Y" +} + +### Panggil endpoint generate via cURL internal +POST https://{{host}}/scheduler/PurchaseInvoiceInstallment/CurlGenerateMonthlyInvoices +Content-Type: application/json + +{ + "baseUrl": "https://accone.aplikasi.web.id/one-api/index.php", + "startDate": "2026-07-01", + "endDate": "2026-07-31", + "userID": 1, + "dryRun": "Y" +} + +### Panggil endpoint generate via cURL internal untuk kontrak tertentu +POST https://{{host}}/scheduler/PurchaseInvoiceInstallment/CurlGenerateMonthlyInvoices +Content-Type: application/json + +{ + "baseUrl": "https://accone.aplikasi.web.id/one-api/index.php", + "startDate": "2026-07-01", + "endDate": "2026-07-31", + "userID": 1, + "contractID": 123, + "dryRun": "N" +} diff --git a/application/controllers/mockup/scheduler/PurchaseInvoiceInstallment.php b/application/controllers/mockup/scheduler/PurchaseInvoiceInstallment.php new file mode 100644 index 0000000..7ff96d1 --- /dev/null +++ b/application/controllers/mockup/scheduler/PurchaseInvoiceInstallment.php @@ -0,0 +1,836 @@ + 0 + * 3. Jumlah cicilan terbayar < total cicilan + * 4. Tanggal kontrak masuk dalam bulan yang dituju + * 5. Invoice dasar (tukar faktur) sudah ada untuk PO tersebut + * 6. PO sudah berstatus Approved + */ + public function GenerateMonthlyInvoices() + { + try { + $para = $this->sys_input; + // Ambil userID dari token JWT (sys_user). Fallback ke 1 jika CRON berjalan tanpa token. + $userID = !empty($this->sys_user["M_UserID"]) ? (int) $this->sys_user["M_UserID"] : 1; + $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")); + $dryRun = isset($para["dryRun"]) && ($para["dryRun"] === true || $para["dryRun"] == "Y" || $para["dryRun"] == "1"); + + // 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, + c.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.ReceiveOrderPoID, + si_base.SupplierInvoiceID AS BaseSupplierInvoiceID, + si_base.SupplierInvoiceNumber AS BaseSupplierInvoiceNumber, + 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 receive_order_po ro + ON ro.ReceiveOrderPoID = c.PurchaseOrderAssetContractReceiveOrderPoID + AND ro.ReceiveOrderPoIsActive = 'Y' + + -- Pastikan tukar faktur (invoice dasar) sudah ada untuk PO ini + JOIN supplier_invoice si_base + ON si_base.SupplierInvoiceID = ( + SELECT MIN(si0.SupplierInvoiceID) + FROM supplier_invoice si0 + WHERE si0.SupplierInvoiceIsActive = 'Y' + AND ( + si0.SupplierInvoiceReceiveOrderPoID = c.PurchaseOrderAssetContractReceiveOrderPoID + OR EXISTS ( + SELECT 1 + FROM supplier_invoice_detail sid0 + WHERE sid0.SupplierInvoiceDetailSupplierInvoiceID = si0.SupplierInvoiceID + AND sid0.SupplierInvoiceDetailReceiveOrderPoID = c.PurchaseOrderAssetContractReceiveOrderPoID + AND sid0.SupplierInvoiceDetailIsActive = 'Y' + ) + ) + ) + + -- 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(?) + ) + AND c.PurchaseOrderAssetContractReceiveOrderPoID > 0 + -- Tanggal jatuh tempo cicilan sudah melewati atau sama dengan hari ini + AND IFNULL(c.PurchaseOrderAssetContractInstallmentDate, 1) <= ?"; + + // Filter kontrak tertentu jika diminta + if (isset($para["contractID"]) && (int) $para["contractID"] > 0) { + $sqlKontrak .= " AND c.PurchaseOrderAssetContractID = ?"; + $params = [$monthEnd, $monthStart, $dayOfMonth, (int) $para["contractID"]]; + } else { + $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(); + print_r($kontraks); + exit; + 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; + } + + // ------------------------------------------------------- + // Mode simulasi (dryRun): catat tanpa simpan ke database + // ------------------------------------------------------- + if ($dryRun) { + $berhasil[] = [ + "kontrakID" => $kontrak["PurchaseOrderAssetContractID"], + "purchaseOrderID" => $kontrak["PurchaseOrderID"], + "receiveOrderPoID" => $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], + "jumlahCicilan" => (float) $kontrak["PurchaseOrderAssetContractInstallmentPayAmount"], + "keterangan" => "Mode simulasi (dryRun), data tidak disimpan ke database." + ]; + continue; + } + + // ------------------------------------------------------- + // Mulai transaksi database + // ------------------------------------------------------- + $this->db->trans_begin(); + + // Generate nomor PI otomatis via stored function fn_penomoran + $nomorPI = $this->generateNomorPI($kontrak, $user); + if ($nomorPI === false) { + $this->db->trans_rollback(); + $this->sys_error_db("Gagal generate nomor Purchase Invoice untuk kontrak ID " . $kontrak["PurchaseOrderAssetContractID"] . ". Pastikan divisi user sudah diatur."); + exit; + } + + $jumlahCicilan = (float) $kontrak["PurchaseOrderAssetContractInstallmentPayAmount"]; + $tanggalJatuhTempo = $this->hitungJatuhTempo($endDate, $kontrak["PurchaseOrderPaymentTerm"]); + $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)); + + $supplierInvoiceID = $this->insertPurchaseInvoice( + $nomorPI, + $kontrak, + $endDate, + $tanggalJatuhTempo, + $jumlahCicilan, + $catatan, + $deskripsi, + $userID + ); + + if ($supplierInvoiceID === false) { + $this->db->trans_rollback(); + $this->sys_error_db("Gagal menyimpan Purchase Invoice cicilan untuk kontrak ID " . $kontrak["PurchaseOrderAssetContractID"] . "."); + exit; + } + + // ------------------------------------------------------- + // UPDATE jumlah cicilan terbayar pada kontrak + // ------------------------------------------------------- + $cicilanTerbayarBaru = ((int) $kontrak["PurchaseOrderAssetContractInstallmentPaid"]) + 1; + $statusKontrakBaru = $cicilanTerbayarBaru >= (int) $kontrak["PurchaseOrderAssetContractInstallmentNumber"] + ? "lunas" + : "belum lunas"; + + $sqlUpdateKontrak = "UPDATE purchase_order_asset_contract + SET PurchaseOrderAssetContractInstallmentPaid = ?, + PurchaseOrderAssetContractStatus = ?, + PurchaseOrderAssetContractLastUpdated = NOW() + WHERE PurchaseOrderAssetContractID = ? + AND PurchaseOrderAssetContractIsActive = 'Y'"; + + $qryUpdateKontrak = $this->db->query($sqlUpdateKontrak, [ + $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()); + } + } + + 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, + c.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.ReceiveOrderPoID, + si_base.SupplierInvoiceID AS BaseSupplierInvoiceID, + si_base.SupplierInvoiceNumber AS BaseSupplierInvoiceNumber, + 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 receive_order_po ro + ON ro.ReceiveOrderPoID = c.PurchaseOrderAssetContractReceiveOrderPoID + AND ro.ReceiveOrderPoIsActive = 'Y' + + JOIN supplier_invoice si_base + ON si_base.SupplierInvoiceID = ( + SELECT MIN(si0.SupplierInvoiceID) + FROM supplier_invoice si0 + WHERE si0.SupplierInvoiceIsActive = 'Y' + AND ( + si0.SupplierInvoiceReceiveOrderPoID = c.PurchaseOrderAssetContractReceiveOrderPoID + OR EXISTS ( + SELECT 1 + FROM supplier_invoice_detail sid0 + WHERE sid0.SupplierInvoiceDetailSupplierInvoiceID = si0.SupplierInvoiceID + AND sid0.SupplierInvoiceDetailReceiveOrderPoID = c.PurchaseOrderAssetContractReceiveOrderPoID + AND sid0.SupplierInvoiceDetailIsActive = 'Y' + ) + ) + ) + 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 c.PurchaseOrderAssetContractReceiveOrderPoID > 0 + 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: 1. + * - dryRun : "Y" untuk simulasi. Default: "N". + * - contractID : (opsional) Filter 1 kontrak tertentu. + */ + public function CurlGenerateMonthlyInvoices() + { + try { + $para = $this->sys_input; + $baseUrl = isset($para["baseUrl"]) && $para["baseUrl"] != "" ? rtrim($para["baseUrl"], "/") : $this->getBaseUrl(); + $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")); + $userID = isset($para["userID"]) && (int) $para["userID"] > 0 ? (int) $para["userID"] : 1; + $dryRun = isset($para["dryRun"]) ? $para["dryRun"] : "N"; + $contractID = isset($para["contractID"]) && (int) $para["contractID"] > 0 ? (int) $para["contractID"] : null; + + $endpoint = $baseUrl . "/tools/PurchaseInvoiceInstallment/GenerateMonthlyInvoices"; + + $payload = [ + "startDate" => $startDate, + "endDate" => $endDate, + "userID" => $userID, + "dryRun" => $dryRun + ]; + + if ($contractID !== null) { + $payload["contractID"] = $contractID; + } + + // Kirim request ke endpoint GenerateMonthlyInvoices + $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, // timeout koneksi: 15 detik + CURLOPT_TIMEOUT => 120 // timeout proses: 2 menit + ]); + + $response = curl_exec($ch); + + if (curl_errno($ch)) { + $pesanError = curl_error($ch); + curl_close($ch); + $this->sys_error("Gagal menghubungi endpoint generate PI cicilan. Detail: " . $pesanError); + exit; + } + + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + + $this->sys_ok([ + "endpoint" => $endpoint, + "httpStatus" => $httpCode, + "response" => $response + ]); + } catch (Exception $exc) { + $this->sys_error($exc->getMessage()); + } + } + + // ========================================================================= + // FUNGSI PRIVATE / HELPER + // ========================================================================= + + /** + * insertPurchaseInvoice + * + * Menyimpan header Purchase Invoice (supplier_invoice) sekaligus + * 1 baris detail-nya (supplier_invoice_detail) dalam satu fungsi. + * + * Fungsi ini TIDAK mengelola transaksi DB — begin/commit/rollback + * tetap menjadi tanggung jawab pemanggil (GenerateMonthlyInvoices). + * + * @param string $nomorPI Nomor PI yang sudah di-generate + * @param array $kontrak Data baris kontrak dari query + * @param string $tanggal Tanggal PI (format YYYY-MM-DD) + * @param string $tanggalJatuhTempo Tanggal jatuh tempo pembayaran (format YYYY-MM-DD) + * @param float $jumlahCicilan Nilai cicilan yang harus dibayar bulan ini + * @param string $catatan Catatan / keterangan pada header PI + * @param string $deskripsi Deskripsi baris detail PI + * @param int $userID ID user yang membuat PI + * + * @return int|false ID supplier_invoice yang baru dibuat, atau false jika salah satu INSERT gagal + */ + private function insertPurchaseInvoice($nomorPI, $kontrak, $tanggal, $tanggalJatuhTempo, $jumlahCicilan, $catatan, $deskripsi, $userID) + { + // ----------------------------------------------------------------- + // 1. INSERT header ke supplier_invoice + // ----------------------------------------------------------------- + $sqlHeader = "INSERT INTO supplier_invoice ( + SupplierInvoiceNumber, + SupplierInvoicePurchaseOrderID, + SupplierInvoiceReceiveOrderPoID, + SupplierInvoiceDate, + SupplierInvoiceDueDate, + SupplierInvoiceDraftPaymentDate, + SupplierInvoiceSupplierID, + SupplierInvoiceSupplierInvoiceNumber, + SupplierInvoiceSupplierInvoiceDate, + SupplierInvoiceSubTotal, + SupplierInvoiceDiscountPercent, + SupplierInvoiceDiscountAmount, + SupplierInvoiceTaxPercentPph, + SupplierInvoiceTaxAmountPph, + SupplierInvoiceTaxPercentPpn, + SupplierInvoiceTaxAmountPpn, + SupplierInvoiceShippingCost, + SupplierInvoiceAdjustmentAmount, + SupplierInvoiceAdjustmentNote, + SupplierInvoiceGrandTotal, + SupplierInvoiceUnpaid, + SupplierInvoiceNote, + SupplierInvoiceStatus, + SupplierInvoiceCreatedUserID + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; + + $qryHeader = $this->db->query($sqlHeader, [ + $nomorPI, + $kontrak["PurchaseOrderID"], + $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], + $tanggal, + $tanggalJatuhTempo, + $tanggal, // draft payment date = tanggal PI + $kontrak["PurchaseOrderSupplierID"], + $kontrak["BaseSupplierInvoiceNumber"], // nomor faktur dasar (tukar faktur) + $tanggal, // tanggal faktur supplier = tanggal PI + $jumlahCicilan, // subtotal + 0, // diskon persen + 0, // diskon rupiah + 0, // PPh persen + 0, // PPh nominal + 0, // PPN persen + 0, // PPN nominal + 0, // ongkos kirim + 0, // penyesuaian + null, // catatan penyesuaian + $jumlahCicilan, // grand total + $jumlahCicilan, // sisa belum terbayar + $catatan, + "Draft", + $userID + ]); + + if (!$qryHeader) { + return false; + } + + $supplierInvoiceID = $this->db->insert_id(); + + // ----------------------------------------------------------------- + // 2. INSERT 1 baris detail ke supplier_invoice_detail + // ----------------------------------------------------------------- + $sqlDetail = "INSERT INTO supplier_invoice_detail ( + SupplierInvoiceDetailSupplierInvoiceID, + SupplierInvoiceDetailPurchaseOrderID, + SupplierInvoiceDetailReceiveOrderPoID, + SupplierInvoiceDetailPurchaseOrderSummaryID, + SupplierInvoiceDetailItemID, + SupplierInvoiceDetailItemUnitID, + SupplierInvoiceDetailDescription, + SupplierInvoiceDetailQty, + SupplierInvoiceDetailPrice, + SupplierInvoiceDetailDiscountPercent, + SupplierInvoiceDetailDiscountDiscountRupiah, + SupplierInvoiceDetailDiscountDiscountType, + SupplierInvoiceDetailDiscountPoProrata, + SupplierInvoiceDetailDiscountAmount, + SupplierInvoiceDetailTotal, + SupplierInvoiceDetailUnpaid, + SupplierInvoiceDetailCreatedUserID + ) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)"; + + $qryDetail = $this->db->query($sqlDetail, [ + $supplierInvoiceID, + $kontrak["PurchaseOrderID"], + $kontrak["PurchaseOrderAssetContractReceiveOrderPoID"], + $kontrak["PurchaseOrderSummaryID"], + $kontrak["PurchaseOrderSummaryItemID"], + $kontrak["PurchaseOrderSummaryItemUnitID"], + $deskripsi, + 1, // qty = 1 (1 periode cicilan) + $jumlahCicilan, // harga satuan = nilai cicilan + 0, // diskon persen + 0, // diskon rupiah + "R", // tipe diskon: Rupiah + 0, // prorata PO + 0, // total diskon + $jumlahCicilan, // total baris + $jumlahCicilan, // sisa belum terbayar + $userID + ]); + + if (!$qryDetail) { + return false; + } + + return $supplierInvoiceID; + } + + /** + * Generate nomor Purchase Invoice otomatis menggunakan stored function fn_penomoran. + * Nomor PI ditentukan berdasarkan tipe dokumen, divisi user, dan area (branch/regional). + * + * @param array $kontrak Data baris kontrak dari query + * @param array $user Data user CRON + * @return string|false Nomor PI yang dihasilkan, atau false jika gagal + */ + private function generateNomorPI($kontrak, $user) + { + // Tentukan tipe area: Regional (R) atau Branch (B) + $tipeArea = $kontrak["PurchaseOrderWarehouseType"] == "R" ? "R" : "B"; + $areaID = (int) $kontrak["PurchaseOrderWarehouseID"]; + + // Fallback ke area user jika warehouse PO tidak terisi + if ($areaID <= 0) { + $areaID = isset($user["M_BranchID"]) ? (int) $user["M_BranchID"] : 0; + if (isset($user["loginLevel"]) && $user["loginLevel"] == "regional") { + $tipeArea = "R"; + $areaID = (int) $user["S_RegionalID"]; + } + } + + // Ambil divisi user untuk keperluan penomoran + $sqlDivisi = "SELECT M_UserDivisionDivisionID + FROM m_userdivision + WHERE M_UserDivisionM_UserID = ? + AND M_UserDivisionIsActive = 'Y' + LIMIT 1"; + + $qryDivisi = $this->db->query($sqlDivisi, [$user["M_UserID"]]); + if (!$qryDivisi || $qryDivisi->num_rows() == 0) { + return false; // User tidak memiliki divisi, nomor PI tidak bisa dibuat + } + + $divisiID = $qryDivisi->row_array()["M_UserDivisionDivisionID"]; + + // Panggil stored function penomoran untuk mendapatkan nomor PI berikutnya + $sqlNomor = "SELECT `fn_penomoran`(?, ?, ?, ?, ?, ?) AS nomorPI"; + $qryNomor = $this->db->query($sqlNomor, ["PI", $divisiID, $tipeArea, $areaID, "SM", "Y"]); + if (!$qryNomor || $qryNomor->num_rows() == 0) { + return false; // Stored function tidak mengembalikan hasil + } + + return $qryNomor->row_array()["nomorPI"]; + } + + /** + * 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 = (int) $term; + 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; + } + + /** + * Deteksi base URL server saat ini secara otomatis (HTTP/HTTPS). + * Digunakan sebagai fallback pada CurlGenerateMonthlyInvoices. + * + * @return string Base URL lengkap termasuk path index.php + */ + private function getBaseUrl() + { + $scheme = "http"; + if ( + (isset($_SERVER["HTTPS"]) && $_SERVER["HTTPS"] !== "off") || + (isset($_SERVER["SERVER_PORT"]) && (int) $_SERVER["SERVER_PORT"] === 443) + ) { + $scheme = "https"; + } + + $host = isset($_SERVER["HTTP_HOST"]) && $_SERVER["HTTP_HOST"] != "" ? $_SERVER["HTTP_HOST"] : "localhost"; + $scriptName = isset($_SERVER["SCRIPT_NAME"]) ? $_SERVER["SCRIPT_NAME"] : "/index.php"; + $basePath = rtrim(str_replace("/index.php", "", $scriptName), "/"); + + return $scheme . "://" . $host . $basePath . "/index.php"; + } +}