load->library('PurchaseService'); } public function index() { $companyId = $this->companycontext->id(); $this->db->select('s.*')->from('suppliers s'); if ($this->db->field_exists('company_id', 'suppliers')) { $this->db->where('s.company_id', $companyId); } $this->db->group_start() ->where('s.is_active', 1) ->or_where( "EXISTS(SELECT 1 FROM supplier_invoices si WHERE si.supplier_id=s.id AND si.status='partial' AND si.balance>0". ($this->db->field_exists('company_id', 'supplier_invoices') ? ' AND si.company_id='.(int)$companyId : '').')', null, false ) ->group_end(); $suppliers = $this->db->order_by('s.name')->get()->result(); $this->db->from('accounts')->where(array('is_active'=>1, 'allow_posting'=>1)); if ($this->db->field_exists('company_id', 'accounts')) { $this->db->where('company_id', $companyId); } $accounts = $this->db ->group_start()->like('nama_akun', 'kas')->or_like('nama_akun', 'bank')->group_end() ->order_by('kode_akun')->get()->result(); $this->db->from('accounts')->where(array('tipe'=>'expense', 'is_active'=>1, 'allow_posting'=>1)); if ($this->db->field_exists('company_id', 'accounts')) { $this->db->where('company_id', $companyId); } $bankChargeAccounts = $this->db->order_by('kode_akun')->get()->result(); $mapping = $this->db->get_where('system_account_mappings', array( 'mapping_key' => 'bank_charge_expense', 'is_active' => 1, ))->row(); $data = array( 'active_menu' => 'payables', 'aging' => $this->purchaseservice->aging($this->input->get('as_of')), 'suppliers' => $suppliers, 'accounts' => $accounts, 'bank_charge_accounts' => $bankChargeAccounts, 'bank_charge_account_id' => $mapping ? (int)$mapping->account_id : 0, ); $this->load->view('partials/header', $data); $this->load->view('payables/index', $data); $this->load->view('partials/footer'); } public function open_bills($supplier) { $supplier = (int)$supplier; $companyId = $this->companycontext->id(); $this->db ->select("si.id,si.internal_no,si.supplier_invoice_no,si.invoice_date,si.due_date,si.total,si.paid_amount,si.balance,si.status,si.payment_plan,CASE WHEN si.due_date<=CURDATE() THEN 'overdue' ELSE 'upcoming' END due_state,po.id po_id,po.po_no,po.order_date,po.payment_timing,po.total po_total,po.paid_amount po_paid_amount,po.subtotal po_subtotal,po.tax_total po_tax_total,po.shipping_cost,po.insurance_cost,po.service_fee,po.discount_amount,po.cashback_amount,GROUP_CONCAT(DISTINCT gr.receipt_no ORDER BY gr.receipt_date,gr.id SEPARATOR ', ') receipt_no", false) ->from('supplier_invoices si') ->join('supplier_invoice_lines sil', 'sil.supplier_invoice_id=si.id', 'left') ->join('purchase_order_lines pol', 'pol.id=sil.po_line_id', 'left') ->join('purchase_orders po', 'po.id=pol.purchase_order_id', 'left') ->join('goods_receipt_lines grl', 'grl.id=sil.receipt_line_id', 'left') ->join('goods_receipts gr', 'gr.id=grl.goods_receipt_id', 'left') ->where('si.supplier_id', $supplier) ->where('si.status', 'partial') ->where('si.balance >', 0); if ($this->db->field_exists('company_id', 'supplier_invoices')) { $this->db->where('si.company_id', $companyId); } $bills = $this->db->group_by('si.id')->order_by('si.due_date')->get()->result(); foreach ($bills as $bill) { $bill->items = $this->db ->select('kb.kode_barang,i.nama_barang,sil.qty,sil.unit_price,sil.tax_amount,sil.line_total') ->from('supplier_invoice_lines sil') ->join('items i', 'i.id=sil.item_id', 'left') ->join('kode_barang kb', 'kb.id=i.kode_id', 'left') ->where('sil.supplier_invoice_id', $bill->id) ->get()->result(); $bill->installment_schedules = $bill->payment_timing === 'installment' ? $this->db->where('purchase_order_id', $bill->po_id)->order_by('installment_no')->get('purchase_installment_schedules')->result() : array(); } $this->db ->select('si.id,si.internal_no,si.supplier_invoice_no,si.balance,si.status,po.po_no') ->from('supplier_invoices si') ->join('supplier_invoice_lines sil', 'sil.supplier_invoice_id=si.id', 'left') ->join('purchase_order_lines pol', 'pol.id=sil.po_line_id', 'left') ->join('purchase_orders po', 'po.id=pol.purchase_order_id', 'left') ->where('si.supplier_id', $supplier) ->where('si.status', 'verified') ->where('si.balance >', 0); if ($this->db->field_exists('company_id', 'supplier_invoices')) { $this->db->where('si.company_id', $companyId); } $pending = $this->db->group_by('si.id')->order_by('si.due_date')->get()->result(); $this->db ->select('sp.id,sp.payment_no,sp.payment_date,sp.amount,sp.bank_charge_amount,sp.total_cash_out,sp.reference_no,spa.amount allocated_amount,si.internal_no,si.supplier_invoice_no') ->from('supplier_payments sp') ->join('supplier_payment_allocations spa', 'spa.supplier_payment_id=sp.id', 'left') ->join('supplier_invoices si', 'si.id=spa.supplier_invoice_id', 'left') ->where('sp.supplier_id', $supplier) ->where('sp.status', 'posted'); if ($this->db->field_exists('company_id', 'supplier_payments')) { $this->db->where('sp.company_id', $companyId); } $history = $this->db->order_by('sp.payment_date', 'DESC')->order_by('sp.id', 'DESC')->limit(100)->get()->result(); foreach ($history as $row) { $row->sources = $this->db ->select('pps.amount,a.kode_akun,a.nama_akun') ->from('purchase_payment_sources pps') ->join('accounts a', 'a.id=pps.account_id') ->where('pps.supplier_payment_id', $row->id) ->get()->result(); } return json_response(true, 'Tagihan dan riwayat supplier.', array( 'bills' => $bills, 'pending' => $pending, 'history' => $history, )); } public function payment() { if (strtoupper($this->input->method()) !== 'POST') { show_404(); } $storedFiles = array(); try { $this->validatePaymentEvidence('payment_documents'); $accounts = (array)$this->input->post('source_account_id'); $amounts = (array)$this->input->post('source_amount'); $sources = array(); foreach ($accounts as $index => $accountId) { if ((int)$accountId) { $sources[(int)$accountId] = ($sources[(int)$accountId] ?? 0) + $this->parseAmount($amounts[$index] ?? 0); } } $allocations = array(); foreach ((array)$this->input->post('allocations') as $invoiceId => $amount) { $allocations[(int)$invoiceId] = $this->parseAmount($amount); } // Transaksi luar memastikan pembayaran, jurnal, alokasi, dan metadata // bukti pembayaran berhasil atau gagal sebagai satu kesatuan. $this->db->trans_begin(); $result = $this->purchaseservice->paymentMultiWithBankCharge( (int)$this->input->post('supplier_id'), $this->input->post('date'), $sources, $allocations, (bool)$this->input->post('allow_advance'), (int)$this->session->userdata('user_id'), $this->input->post('reference_no', true), $this->parseAmount($this->input->post('bank_charge_amount')), (int)$this->input->post('bank_charge_account_id') ); $storedFiles = $this->storePaymentEvidence($result['payment_id'], 'payment_documents'); if ($this->db->trans_status() === false) { throw new BusinessException('Pembayaran dan bukti pembayaran gagal disimpan secara lengkap.'); } $this->db->trans_commit(); return json_response(true, 'Pembayaran supplier berhasil diposting.', array('result'=>$result)); } catch (Throwable $e) { if ($this->db->trans_depth() > 0) { $this->db->trans_rollback(); } foreach ($storedFiles as $path) { if (is_file($path)) @unlink($path); } return business_exception_response($e); } } private function validatePaymentEvidence($input) { $files = $_FILES[$input] ?? array(); $names = array_values(array_filter((array)($files['name'] ?? array()), function ($name) { return trim((string)$name) !== ''; })); if (!$names) { throw new BusinessException('Bukti pembayaran wajib diunggah sebelum pembayaran diposting.'); } if (count($names) > 10) { throw new BusinessException('Maksimal 10 bukti pembayaran untuk satu transaksi.'); } $extensions = array('pdf','jpg','jpeg','jpe','jfif','png','webp','gif','bmp','tif','tiff','heic','heif','avif','jp2','j2k','jpf','jpg2','jpx','ico'); foreach ((array)$files['name'] as $index => $name) { if (trim((string)$name) === '') continue; $error = (int)($files['error'][$index] ?? UPLOAD_ERR_NO_FILE); if ($error !== UPLOAD_ERR_OK) { throw new BusinessException( in_array($error, array(UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE), true) ? 'Ukuran file '.$name.' melebihi batas server.' : 'File '.$name.' gagal diterima oleh server (kode '.$error.').' ); } if ((int)($files['size'][$index] ?? 0) > 5 * 1024 * 1024) { throw new BusinessException('Ukuran file '.$name.' melebihi 5 MB.'); } $extension = strtolower(pathinfo($name, PATHINFO_EXTENSION)); if (!in_array($extension, $extensions, true)) { throw new BusinessException('Format .'.$extension.' tidak diizinkan. Gunakan PDF atau file gambar.'); } $temporary = $files['tmp_name'][$index] ?? ''; $mime = $temporary && is_file($temporary) && function_exists('finfo_open') ? (new finfo(FILEINFO_MIME_TYPE))->file($temporary) : ($files['type'][$index] ?? ''); if ($mime !== 'application/pdf' && strpos((string)$mime, 'image/') !== 0) { throw new BusinessException('Isi file '.$name.' bukan PDF atau gambar yang valid.'); } } } private function storePaymentEvidence($paymentId, $input) { if (!(int)$paymentId) { throw new BusinessException('Nomor pembayaran tidak valid untuk penyimpanan bukti.'); } $files = $_FILES[$input]; $directory = FCPATH.'uploads/accounting/'; if (!is_dir($directory) && !mkdir($directory, 0750, true)) { throw new BusinessException('Folder bukti pembayaran tidak tersedia.'); } $this->load->library('upload'); $stored = array(); try { foreach ((array)$files['name'] as $index => $name) { if (trim((string)$name) === '') continue; $_FILES['attachment'] = array( 'name' => $name, 'type' => $files['type'][$index], 'tmp_name' => $files['tmp_name'][$index], 'error' => $files['error'][$index], 'size' => $files['size'][$index], ); $this->upload->initialize(array( 'upload_path' => $directory, 'allowed_types' => 'pdf|jpg|jpeg|jpe|jfif|png|webp|gif|bmp|tif|tiff|heic|heif|avif|jp2|j2k|jpf|jpg2|jpx|ico', 'max_size' => 5120, 'encrypt_name' => true, )); if (!$this->upload->do_upload('attachment')) { throw new BusinessException('File '.$name.' ditolak: '.strip_tags($this->upload->display_errors())); } $file = $this->upload->data(); $stored[] = $file['full_path']; $this->db->insert('transaction_attachments', array( 'module' => 'purchase', 'entity_type' => 'supplier_payment', 'entity_id' => (int)$paymentId, 'original_name' => $file['orig_name'], 'stored_name' => $file['file_name'], 'mime_type' => $file['file_type'], 'file_size' => $file['file_size'] * 1024, 'sha256' => hash_file('sha256', $file['full_path']), 'uploaded_by' => (int)$this->session->userdata('user_id'), 'created_at' => date('Y-m-d H:i:s'), )); if (!$this->db->affected_rows()) { throw new BusinessException('Metadata bukti pembayaran gagal disimpan.'); } } return $stored; } catch (Throwable $e) { foreach ($stored as $path) { if (is_file($path)) @unlink($path); } throw $e; } } public function statement($supplier) { $companyId = $this->companycontext->id(); $this->db->where('id', (int)$supplier); if ($this->db->field_exists('company_id', 'suppliers')) { $this->db->where('company_id', $companyId); } $supplierRow = $this->db->get('suppliers')->row(); if (!$supplierRow) { show_404(); } $this->db->where('supplier_id', $supplierRow->id); if ($this->db->field_exists('company_id', 'supplier_invoices')) { $this->db->where('company_id', $companyId); } $bills = $this->db->order_by('invoice_date')->get('supplier_invoices')->result(); $this->db->where('supplier_id', $supplierRow->id); if ($this->db->field_exists('company_id', 'supplier_payments')) { $this->db->where('company_id', $companyId); } $payments = $this->db->order_by('payment_date')->get('supplier_payments')->result(); $data = array('active_menu'=>'payables', 'supplier'=>$supplierRow, 'bills'=>$bills, 'payments'=>$payments); $this->load->view('partials/header', $data); $this->load->view('payables/statement', $data); $this->load->view('partials/footer'); } /** Mendukung format Indonesia (1.250.000,50) maupun angka normal (1250000.50). */ private function parseAmount($value) { $value = trim((string)$value); if ($value === '') return 0.0; $value = preg_replace('/[^0-9,\.\-]/', '', $value); if (strpos($value, ',') !== false) { return (float)str_replace(',', '.', str_replace('.', '', $value)); } if (preg_match('/^-?\d{1,3}(?:\.\d{3})+$/', $value)) { return (float)str_replace('.', '', $value); } return (float)$value; } }