Compare commits

...

11 Commits

Author SHA1 Message Date
Wian Drs 2a77402c60 bank, laporan bank, budgeting badge 2026-09-17 00:51:53 +07:00
Wian Drs e4d6245a3d pemindahan submenu peralatan teknisi ke menu Oprasional Peralatan 2026-09-15 11:47:49 +07:00
Wian Drs 8fedd39d8f hr dan payroll 2026-09-15 10:38:43 +07:00
Wian Drs 5e1fd11410 perbaikan bug global loading HR dan Payroll 2026-09-14 12:05:53 +07:00
Wian Drs 63993ee76e update HR dan Payroll 2026-09-14 11:14:25 +07:00
Wian Drs ec94783db4 update di penjualan invoice 2026-09-12 01:45:03 +07:00
Wian Drs e43bda8d6b update user company UI CSS 2026-09-11 23:35:24 +07:00
Wian Drs b011272f28 update user company 2026-09-11 18:42:12 +07:00
Wian Drs 387ca54679 update login page 2026-09-11 18:26:50 +07:00
Wian Drs ff870b7667 update koneksi DB 2026-09-11 18:06:17 +07:00
Wian Drs 4ef15c22ae Update besar apk finance 2026-09-11 16:03:00 +07:00
946 changed files with 103244 additions and 17489 deletions
+36
View File
@@ -0,0 +1,36 @@
# Salin nilai ini ke environment Apache/PHP. Jangan commit nilai rahasia.
CI_ENV=development
APP_BASE_URL=http://localhost/accounting_dev/
APP_ENCRYPTION_KEY=generate-a-random-secret-at-least-32-characters
COOKIE_SECURE=false
# Batas teknis cookie/session storage. Batas login tiap user tetap divalidasi server.
SESSION_EXPIRATION=604800
WORKFLOW_REQUIRE_SEPARATE_APPROVER=false
DB_HOST=172.0.0.1
DB_PORT=3306
DB_USERNAME=
DB_PASSWORD=
DB_DATABASE=
# DB_HOST=103.242.106.56
# DB_PORT=3309
# DB_USERNAME=keuangan_prod_v2
# DB_PASSWORD=keuangan_prod_v2
# DB_DATABASE=drk4pfRi6bamKyFG
# Daftar serial mesin absensi dipisahkan koma. Wajib pada production.
ATTENDANCE_ALLOWED_SERIALS=
# Fitur diagnostik ini nonaktif secara default dan hanya dapat dipakai Admin.
DBCOMPARE_ENABLED=false
DBCOMPARE_SOURCE_HOST=
DBCOMPARE_SOURCE_PORT=3306
DBCOMPARE_SOURCE_USERNAME=
DBCOMPARE_SOURCE_PASSWORD=
DBCOMPARE_SOURCE_DATABASE=
DBCOMPARE_TARGET_HOST=
DBCOMPARE_TARGET_PORT=3306
DBCOMPARE_TARGET_USERNAME=
DBCOMPARE_TARGET_PASSWORD=
DBCOMPARE_TARGET_DATABASE=
+7 -4
View File
@@ -1,11 +1,14 @@
.DS_Store
application/config/config.php
application/config/database.php
application/controllers/Dbcompare.php
.htaccess
.env
.env.*
!.env.example
uploads/temp_qr/*
uploads/karyawan/*
uploads/card/background_*
!uploads/karyawan/.gitkeep
!uploads/.htaccess
application/cache/*
!application/cache/index.html
+22
View File
@@ -0,0 +1,22 @@
<IfModule mod_rewrite.c>
RewriteEngine On
RewriteBase /accounting_dev
# Redirect requests to index.php
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)$ index.php?/$1 [L]
</IfModule>
Options -Indexes
<FilesMatch "(^\.|composer\.(json|lock)$|.*\.(env|ini|log|sql|bak|dist))$">
Require all denied
</FilesMatch>
<IfModule mod_headers.c>
Header always set X-Content-Type-Options "nosniff"
Header always set X-Frame-Options "SAMEORIGIN"
Header always set Referrer-Policy "strict-origin-when-cross-origin"
Header always set Permissions-Policy "camera=(self), geolocation=(), microphone=()"
</IfModule>
+1
View File
@@ -0,0 +1 @@
{"version":1,"defects":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":3,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":3,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":4,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":3,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":3,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":3,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":3,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":3},"times":{"ApplicationContractTest::testInternalControllersUseAuthenticatedBaseController":0.087,"ApplicationContractTest::testAjaxSessionExpiryReturnsJson401":0.056,"ApplicationContractTest::testResponsiveShellHasDesktopAndMobileRules":0.11,"ApplicationContractTest::testUiPreventsRepeatedSubmitAndHandlesAjaxFailure":0.001,"AccountingDatabaseTest::testAccountingInvariantsAgainstDevelopmentDatabase":0.9,"FormulaServiceTest::testPayrollFormulaHonorsPrecedenceAndVariables":0.008,"FormulaServiceTest::testProrataFormulaAndRounding":0,"FormulaServiceTest::testUnknownVariableIsZeroForBackwardCompatibleFormula":0,"FormulaServiceTest::testRejectsDivisionByZero":0.002,"FormulaServiceTest::testRejectsUnsafeTokens":0,"ApplicationContractTest::testMoneyInputsUseCentralParserBeforeAjax":0.157,"ApplicationContractTest::testMasterAdminHasFullAccessAndApprovalOverride":0.001,"ApplicationContractTest::testBalanceSheetAndProfitLossRemainInNavigation":0,"ApplicationContractTest::testProfessionalReportsHaveIndonesianFinancialStatements":0.001,"ApplicationContractTest::testPurchasePaymentsSeparatePrincipalAndBankCharge":0.002,"ApplicationContractTest::testPurchaseReturnsAreSeparatedAndIntegrated":0.003,"AccountingDatabaseTest::testPurchaseAdjustmentInvariantsAgainstDevelopmentDatabase":0.47,"ApplicationContractTest::testPurchaseAdjustmentsSupportReversalAuditAndProfessionalReports":0.002,"AccountingDatabaseTest::testPurchaseAdjustmentQueriesAndReportsRunAgainstActualSchema":0.615,"ApplicationContractTest::testProfessionalInventoryUsesOneLedgerAndSafeWorkflow":0.003,"AccountingDatabaseTest::testProfessionalInventoryRuntimeAgainstDevelopmentDatabase":0.603,"ApplicationContractTest::testInventoryFinalizationCoversRequiredOperationalScenarios":0.003,"ApplicationContractTest::testInventoryWriteEndpointsAndButtonsArePermissionAware":0.001,"AccountingDatabaseTest::testInventoryIntegrityAndNegativeStockGuardAgainstDevelopmentDatabase":0.611,"AccountingDatabaseTest::testInventoryReconciliationAlwaysProducesAuditableNumbers":0.224,"ApplicationContractTest::testFixedAssetsUseSingleProfessionalWorkflow":0.065,"AccountingDatabaseTest::testFixedAssetIntegrityAndRuntimeQueries":0.473}}
+78
View File
@@ -0,0 +1,78 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
// Fallback kompatibilitas. Nilai database pada system_account_mappings akan
// mengambil prioritas setelah migration Tahap 2 dijalankan.
$config['system_accounts'] = array(
'inventory' => 21,
'cost_of_goods_sold' => 63,
'accounts_receivable' => 19,
'deferred_revenue' => 78,
'inventory_in_sales' => 80,
'goods_revenue' => 62,
'customer_savings' => 70,
'depreciation_expense' => 54,
'sales_tax_payable' => 0,
'customer_advances' => 0,
'bad_debt_expense' => 0,
'asset_disposal_gain' => 0,
'asset_disposal_loss' => 0
,'payroll_expense' => 0
,'payroll_payable' => 0
,'payroll_tax_payable' => 0
,'payroll_bpjs_payable' => 0
,'employee_loan_receivable' => 0
,'realized_fx_gain' => 0
,'realized_fx_loss' => 0
,'unrealized_fx_gain' => 0
,'unrealized_fx_loss' => 0
);
$config['document_numbering'] = array(
'journal' => array('prefix' => 'JR', 'padding' => 5),
'invoice_journal' => array('prefix' => 'INV', 'padding' => 4),
'invoice' => array('prefix' => 'INV', 'padding' => 4),
'payment' => array('prefix' => 'PAY', 'padding' => 5),
'reversal' => array('prefix' => 'REV', 'padding' => 5),
'quotation' => array('prefix' => 'QUO', 'padding' => 5),
'sales_order' => array('prefix' => 'SO', 'padding' => 5),
'credit_note' => array('prefix' => 'CN', 'padding' => 5),
'refund' => array('prefix' => 'RF', 'padding' => 5),
'writeoff' => array('prefix' => 'WO', 'padding' => 5),
'receipt' => array('prefix' => 'RCT', 'padding' => 6),
'purchase_request' => array('prefix' => 'PR', 'padding' => 5),
'purchase_order' => array('prefix' => 'PO', 'padding' => 5),
'goods_receipt' => array('prefix' => 'GR', 'padding' => 5),
'supplier_invoice' => array('prefix' => 'BILL', 'padding' => 5),
'supplier_payment' => array('prefix' => 'SPAY', 'padding' => 5),
'purchase_return' => array('prefix' => 'PRET', 'padding' => 5),
'purchase_return_shipment' => array('prefix' => 'RSH', 'padding' => 5),
'purchase_replacement' => array('prefix' => 'RPL', 'padding' => 5),
'supplier_refund' => array('prefix' => 'SRF', 'padding' => 5),
'supplier_refund_receipt' => array('prefix' => 'SRR', 'padding' => 5),
'supplier_debit_note' => array('prefix' => 'SDN', 'padding' => 5)
,'sales_delivery' => array('prefix' => 'SJ', 'padding' => 5)
,'sales_delivery_journal' => array('prefix' => 'JSJ', 'padding' => 5)
,'sales_return' => array('prefix' => 'SRET', 'padding' => 5)
,'revenue_recognition' => array('prefix' => 'REVREC', 'padding' => 5)
,'invoice_line_journal' => array('prefix' => 'JIL', 'padding' => 6)
,'invoice_line_reversal' => array('prefix' => 'RIL', 'padding' => 6)
,'stock_document' => array('prefix' => 'STK', 'padding' => 6)
,'stock_reservation' => array('prefix' => 'RSV', 'padding' => 6)
,'asset_event' => array('prefix' => 'AE', 'padding' => 6)
,'fixed_asset' => array('prefix' => 'AST', 'padding' => 6)
,'asset_maintenance' => array('prefix' => 'MNT', 'padding' => 6)
,'asset_opname' => array('prefix' => 'AOP', 'padding' => 6)
,'cash_transaction' => array('prefix' => 'CB', 'padding' => 6)
,'cash_transfer' => array('prefix' => 'TRF', 'padding' => 6)
,'bank_reconciliation' => array('prefix' => 'BRC', 'padding' => 6)
,'payroll' => array('prefix' => 'PRL', 'padding' => 6)
,'payroll_payment' => array('prefix' => 'PRP', 'padding' => 6)
,'technician_equipment' => array('prefix' => 'TEQ', 'padding' => 6)
,'leave_request' => array('prefix' => 'LV', 'padding' => 6)
);
$config['require_separate_journal_approver'] = filter_var(
app_env('WORKFLOW_REQUIRE_SEPARATE_APPROVER', 'false'),
FILTER_VALIDATE_BOOLEAN
);
+1 -1
View File
@@ -89,7 +89,7 @@ $autoload['drivers'] = array();
|
| $autoload['helper'] = array('url', 'file');
*/
$autoload['helper'] = array('url', 'file', 'permission', 'activity', 'indo_date');
$autoload['helper'] = array('url', 'file', 'permission', 'activity', 'indo_date', 'response');
/*
| -------------------------------------------------------------------
+539
View File
@@ -0,0 +1,539 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/*
|--------------------------------------------------------------------------
| Base Site URL
|--------------------------------------------------------------------------
|
| URL to your CodeIgniter root. Typically this will be your base URL,
| WITH a trailing slash:
|
| http://example.com/
|
| WARNING: You MUST set this value!
|
| If it is not set, then CodeIgniter will try to guess the protocol and
| path to your installation, but due to security concerns the hostname will
| be set to $_SERVER['SERVER_ADDR'] if available, or localhost otherwise.
| The auto-detection mechanism exists only for convenience during
| development and MUST NOT be used in production!
|
| If you need to allow multiple domains, remember that this file is still
| a PHP script and you can easily do that on your own.
|
*/
$config['base_url'] = rtrim(app_env('APP_BASE_URL', 'http://localhost/accounting_dev/') ?: 'http://localhost/accounting_dev/', '/') . '/';
/*
|--------------------------------------------------------------------------
| Index File
|--------------------------------------------------------------------------
|
| Typically this will be your index.php file, unless you've renamed it to
| something else. If you are using mod_rewrite to remove the page set this
| variable so that it is blank.
|
*/
$config['index_page'] = '';
/*
|--------------------------------------------------------------------------
| URI PROTOCOL
|--------------------------------------------------------------------------
|
| This item determines which server global should be used to retrieve the
| URI string. The default setting of 'REQUEST_URI' works for most servers.
| If your links do not seem to work, try one of the other delicious flavors:
|
| 'REQUEST_URI' Uses $_SERVER['REQUEST_URI']
| 'QUERY_STRING' Uses $_SERVER['QUERY_STRING']
| 'PATH_INFO' Uses $_SERVER['PATH_INFO']
|
| WARNING: If you set this to 'PATH_INFO', URIs will always be URL-decoded!
*/
$config['uri_protocol'] = 'REQUEST_URI';
/*
|--------------------------------------------------------------------------
| URL suffix
|--------------------------------------------------------------------------
|
| This option allows you to add a suffix to all URLs generated by CodeIgniter.
| For more information please see the user guide:
|
| https://codeigniter.com/userguide3/general/urls.html
|
| Note: This option is ignored for CLI requests.
*/
$config['url_suffix'] = '';
/*
|--------------------------------------------------------------------------
| Default Language
|--------------------------------------------------------------------------
|
| This determines which set of language files should be used. Make sure
| there is an available translation if you intend to use something other
| than english.
|
*/
$config['language'] = 'english';
/*
|--------------------------------------------------------------------------
| Default Character Set
|--------------------------------------------------------------------------
|
| This determines which character set is used by default in various methods
| that require a character set to be provided.
|
| See http://php.net/htmlspecialchars for a list of supported charsets.
|
*/
$config['charset'] = 'UTF-8';
/*
|--------------------------------------------------------------------------
| Enable/Disable System Hooks
|--------------------------------------------------------------------------
|
| If you would like to use the 'hooks' feature you must enable it by
| setting this variable to TRUE (boolean). See the user guide for details.
|
*/
$config['enable_hooks'] = FALSE;
/*
|--------------------------------------------------------------------------
| Class Extension Prefix
|--------------------------------------------------------------------------
|
| This item allows you to set the filename/classname prefix when extending
| native libraries. For more information please see the user guide:
|
| https://codeigniter.com/userguide3/general/core_classes.html
| https://codeigniter.com/userguide3/general/creating_libraries.html
|
*/
$config['subclass_prefix'] = 'MY_';
/*
|--------------------------------------------------------------------------
| Composer auto-loading
|--------------------------------------------------------------------------
|
| Enabling this setting will tell CodeIgniter to look for a Composer
| package auto-loader script in application/vendor/autoload.php.
|
| $config['composer_autoload'] = TRUE;
|
| Or if you have your vendor/ directory located somewhere else, you
| can opt to set a specific path as well:
|
| $config['composer_autoload'] = '/path/to/vendor/autoload.php';
|
| For more information about Composer, please visit http://getcomposer.org/
|
| Note: This will NOT disable or override the CodeIgniter-specific
| autoloading (application/config/autoload.php)
*/
$config['composer_autoload'] = FALSE;
/*
|--------------------------------------------------------------------------
| Allowed URL Characters
|--------------------------------------------------------------------------
|
| This lets you specify which characters are permitted within your URLs.
| When someone tries to submit a URL with disallowed characters they will
| get a warning message.
|
| As a security measure you are STRONGLY encouraged to restrict URLs to
| as few characters as possible. By default only these are allowed: a-z 0-9~%.:_-
|
| Leave blank to allow all characters -- but only if you are insane.
|
| The configured value is actually a regular expression character group
| and it will be executed as: ! preg_match('/^[<permitted_uri_chars>]+$/i
|
| DO NOT CHANGE THIS UNLESS YOU FULLY UNDERSTAND THE REPERCUSSIONS!!
|
*/
$config['permitted_uri_chars'] = 'a-z 0-9~%.:_\-';
/*
|--------------------------------------------------------------------------
| Enable Query Strings
|--------------------------------------------------------------------------
|
| By default CodeIgniter uses search-engine friendly segment based URLs:
| example.com/who/what/where/
|
| You can optionally enable standard query string based URLs:
| example.com?who=me&what=something&where=here
|
| Options are: TRUE or FALSE (boolean)
|
| The other items let you set the query string 'words' that will
| invoke your controllers and its functions:
| example.com/index.php?c=controller&m=function
|
| Please note that some of the helpers won't work as expected when
| this feature is enabled, since CodeIgniter is designed primarily to
| use segment based URLs.
|
*/
$config['enable_query_strings'] = FALSE;
$config['controller_trigger'] = 'c';
$config['function_trigger'] = 'm';
$config['directory_trigger'] = 'd';
/*
|--------------------------------------------------------------------------
| Allow $_GET array
|--------------------------------------------------------------------------
|
| By default CodeIgniter enables access to the $_GET array. If for some
| reason you would like to disable it, set 'allow_get_array' to FALSE.
|
| WARNING: This feature is DEPRECATED and currently available only
| for backwards compatibility purposes!
|
*/
$config['allow_get_array'] = TRUE;
/*
|--------------------------------------------------------------------------
| Error Logging Threshold
|--------------------------------------------------------------------------
|
| You can enable error logging by setting a threshold over zero. The
| threshold determines what gets logged. Threshold options are:
|
| 0 = Disables logging, Error logging TURNED OFF
| 1 = Error Messages (including PHP errors)
| 2 = Debug Messages
| 3 = Informational Messages
| 4 = All Messages
|
| You can also pass an array with threshold levels to show individual error types
|
| array(2) = Debug Messages, without Error Messages
|
| For a live site you'll usually only enable Errors (1) to be logged otherwise
| your log files will fill up very fast.
|
*/
$config['log_threshold'] = 4;
/*
|--------------------------------------------------------------------------
| Error Logging Directory Path
|--------------------------------------------------------------------------
|
| Leave this BLANK unless you would like to set something other than the default
| application/logs/ directory. Use a full server path with trailing slash.
|
*/
$config['log_path'] = '';
/*
|--------------------------------------------------------------------------
| Log File Extension
|--------------------------------------------------------------------------
|
| The default filename extension for log files. The default 'php' allows for
| protecting the log files via basic scripting, when they are to be stored
| under a publicly accessible directory.
|
| Note: Leaving it blank will default to 'php'.
|
*/
$config['log_file_extension'] = '';
/*
|--------------------------------------------------------------------------
| Log File Permissions
|--------------------------------------------------------------------------
|
| The file system permissions to be applied on newly created log files.
|
| IMPORTANT: This MUST be an integer (no quotes) and you MUST use octal
| integer notation (i.e. 0700, 0644, etc.)
*/
$config['log_file_permissions'] = 0644;
/*
|--------------------------------------------------------------------------
| Date Format for Logs
|--------------------------------------------------------------------------
|
| Each item that is logged has an associated date. You can use PHP date
| codes to set your own date formatting
|
*/
$config['log_date_format'] = 'Y-m-d H:i:s';
/*
|--------------------------------------------------------------------------
| Error Views Directory Path
|--------------------------------------------------------------------------
|
| Leave this BLANK unless you would like to set something other than the default
| application/views/errors/ directory. Use a full server path with trailing slash.
|
*/
$config['error_views_path'] = '';
/*
|--------------------------------------------------------------------------
| Cache Directory Path
|--------------------------------------------------------------------------
|
| Leave this BLANK unless you would like to set something other than the default
| application/cache/ directory. Use a full server path with trailing slash.
|
*/
$config['cache_path'] = '';
/*
|--------------------------------------------------------------------------
| Cache Include Query String
|--------------------------------------------------------------------------
|
| Whether to take the URL query string into consideration when generating
| output cache files. Valid options are:
|
| FALSE = Disabled
| TRUE = Enabled, take all query parameters into account.
| Please be aware that this may result in numerous cache
| files generated for the same page over and over again.
| array('q') = Enabled, but only take into account the specified list
| of query parameters.
|
*/
$config['cache_query_string'] = FALSE;
/*
|--------------------------------------------------------------------------
| Encryption Key
|--------------------------------------------------------------------------
|
| If you use the Encryption class, you must set an encryption key.
| See the user guide for more info.
|
| https://codeigniter.com/userguide3/libraries/encryption.html
|
*/
$config['encryption_key'] = app_env('APP_ENCRYPTION_KEY', 'development-only-change-this-key') ?: 'development-only-change-this-key';
if (ENVIRONMENT === 'production' && $config['encryption_key'] === 'development-only-change-this-key') {
show_error('APP_ENCRYPTION_KEY wajib dikonfigurasi pada environment production.', 500);
}
/*
|--------------------------------------------------------------------------
| Session Variables
|--------------------------------------------------------------------------
|
| 'sess_driver'
|
| The storage driver to use: files, database, redis, memcached
|
| 'sess_cookie_name'
|
| The session cookie name, must contain only [0-9a-z_-] characters
|
| 'sess_samesite'
|
| Session cookie SameSite attribute: Lax (default), Strict or None
|
| 'sess_expiration'
|
| The number of SECONDS you want the session to last.
| Setting to 0 (zero) means expire when the browser is closed.
|
| 'sess_save_path'
|
| The location to save sessions to, driver dependent.
|
| For the 'files' driver, it's a path to a writable directory.
| WARNING: Only absolute paths are supported!
|
| For the 'database' driver, it's a table name.
| Please read up the manual for the format with other session drivers.
|
| IMPORTANT: You are REQUIRED to set a valid save path!
|
| 'sess_match_ip'
|
| Whether to match the user's IP address when reading the session data.
|
| WARNING: If you're using the database driver, don't forget to update
| your session table's PRIMARY KEY when changing this setting.
|
| 'sess_time_to_update'
|
| How many seconds between CI regenerating the session ID.
|
| 'sess_regenerate_destroy'
|
| Whether to destroy session data associated with the old session ID
| when auto-regenerating the session ID. When set to FALSE, the data
| will be later deleted by the garbage collector.
|
| Other session cookie settings are shared with the rest of the application,
| except for 'cookie_prefix' and 'cookie_httponly', which are ignored here.
|
*/
$config['sess_driver'] = app_env('SESSION_DRIVER', 'database') ?: 'database';
$config['sess_cookie_name'] = 'accounting_session';
$config['sess_samesite'] = 'Lax';
// Cookie/storage memakai batas teknis terpanjang (7 hari). Hard expiry setiap
// pengguna tetap dihitung dari login pertama oleh UserSessionService.
$config['sess_expiration'] = (int) (app_env('SESSION_EXPIRATION', 604800) ?: 604800);
$config['sess_save_path'] = $config['sess_driver'] === 'database' ? 'ci_sessions' : sys_get_temp_dir();
$config['sess_match_ip'] = FALSE;
$config['sess_time_to_update'] = 300;
$config['sess_regenerate_destroy'] = FALSE;
/*
|--------------------------------------------------------------------------
| Cookie Related Variables
|--------------------------------------------------------------------------
|
| 'cookie_prefix' = Set a cookie name prefix if you need to avoid collisions
| 'cookie_domain' = Set to .your-domain.com for site-wide cookies
| 'cookie_path' = Typically will be a forward slash
| 'cookie_secure' = Cookie will only be set if a secure HTTPS connection exists.
| 'cookie_httponly' = Cookie will only be accessible via HTTP(S) (no javascript)
| 'cookie_samesite' = Cookie's samesite attribute (Lax, Strict or None)
|
| Note: These settings (with the exception of 'cookie_prefix' and
| 'cookie_httponly') will also affect sessions.
|
*/
$config['cookie_prefix'] = 'accounting_';
$config['cookie_domain'] = '';
$config['cookie_path'] = '/';
$config['cookie_secure'] = filter_var(app_env('COOKIE_SECURE', ENVIRONMENT === 'production' ? 'true' : 'false'), FILTER_VALIDATE_BOOLEAN);
$config['cookie_httponly'] = TRUE;
$config['cookie_samesite'] = 'Lax';
/*
|--------------------------------------------------------------------------
| Standardize newlines
|--------------------------------------------------------------------------
|
| Determines whether to standardize newline characters in input data,
| meaning to replace \r\n, \r, \n occurrences with the PHP_EOL value.
|
| WARNING: This feature is DEPRECATED and currently available only
| for backwards compatibility purposes!
|
*/
$config['standardize_newlines'] = FALSE;
/*
|--------------------------------------------------------------------------
| Global XSS Filtering
|--------------------------------------------------------------------------
|
| Determines whether the XSS filter is always active when GET, POST or
| COOKIE data is encountered
|
| WARNING: This feature is DEPRECATED and currently available only
| for backwards compatibility purposes!
|
*/
$config['global_xss_filtering'] = FALSE;
/*
|--------------------------------------------------------------------------
| Cross Site Request Forgery
|--------------------------------------------------------------------------
| Enables a CSRF cookie token to be set. When set to TRUE, token will be
| checked on a submitted form. If you are accepting user data, it is strongly
| recommended CSRF protection be enabled.
|
| 'csrf_token_name' = The token name
| 'csrf_cookie_name' = The cookie name
| 'csrf_expire' = The number in seconds the token should expire.
| 'csrf_regenerate' = Regenerate token on every submission
| 'csrf_exclude_uris' = Array of URIs which ignore CSRF checks
*/
$config['csrf_protection'] = TRUE;
$config['csrf_token_name'] = 'accounting_csrf_token';
$config['csrf_cookie_name'] = 'accounting_csrf_cookie';
$config['csrf_expire'] = 7200;
$config['csrf_regenerate'] = FALSE;
$config['csrf_exclude_uris'] = array('iclock/cdata');
/*
|--------------------------------------------------------------------------
| Output Compression
|--------------------------------------------------------------------------
|
| Enables Gzip output compression for faster page loads. When enabled,
| the output class will test whether your server supports Gzip.
| Even if it does, however, not all browsers support compression
| so enable only if you are reasonably sure your visitors can handle it.
|
| Only used if zlib.output_compression is turned off in your php.ini.
| Please do not use it together with httpd-level output compression.
|
| VERY IMPORTANT: If you are getting a blank page when compression is enabled it
| means you are prematurely outputting something to your browser. It could
| even be a line of whitespace at the end of one of your scripts. For
| compression to work, nothing can be sent before the output buffer is called
| by the output class. Do not 'echo' any values with compression enabled.
|
*/
$config['compress_output'] = FALSE;
/*
|--------------------------------------------------------------------------
| Master Time Reference
|--------------------------------------------------------------------------
|
| Options are 'local' or any PHP supported timezone. This preference tells
| the system whether to use your server's local time as the master 'now'
| reference, or convert it to the configured one timezone. See the 'date
| helper' page of the user guide for information regarding date handling.
|
*/
$config['time_reference'] = 'local';
/*
|--------------------------------------------------------------------------
| Rewrite PHP Short Tags
|--------------------------------------------------------------------------
|
| If your PHP installation does not have short tag support enabled CI
| can rewrite the tags on-the-fly, enabling you to utilize that syntax
| in your view files. Options are TRUE or FALSE (boolean)
|
| Note: You need to have eval() enabled for this to work.
|
*/
$config['rewrite_short_tags'] = FALSE;
/*
|--------------------------------------------------------------------------
| Reverse Proxy IPs
|--------------------------------------------------------------------------
|
| If your server is behind a reverse proxy, you must whitelist the proxy
| IP addresses from which CodeIgniter should trust headers such as
| HTTP_X_FORWARDED_FOR and HTTP_CLIENT_IP in order to properly identify
| the visitor's IP address.
|
| You can use both an array or a comma-separated list of proxy addresses,
| as well as specifying whole subnets. Here are a few examples:
|
| Comma-separated: '10.0.1.200,192.168.5.0/24'
| Array: array('10.0.1.200', '192.168.5.0/24')
*/
$config['proxy_ips'] = '';
+99
View File
@@ -0,0 +1,99 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/*
| -------------------------------------------------------------------
| DATABASE CONNECTIVITY SETTINGS
| -------------------------------------------------------------------
| This file will contain the settings needed to access your database.
|
| For complete instructions please consult the 'Database Connection'
| page of the User Guide.
|
| -------------------------------------------------------------------
| EXPLANATION OF VARIABLES
| -------------------------------------------------------------------
|
| ['dsn'] The full DSN string describe a connection to the database.
| ['hostname'] The hostname of your database server.
| ['username'] The username used to connect to the database
| ['password'] The password used to connect to the database
| ['database'] The name of the database you want to connect to
| ['dbdriver'] The database driver. e.g.: mysqli.
| Currently supported:
| cubrid, ibase, mssql, mysql, mysqli, oci8,
| odbc, pdo, postgre, sqlite, sqlite3, sqlsrv
| ['dbprefix'] You can add an optional prefix, which will be added
| to the table name when using the Query Builder class
| ['pconnect'] TRUE/FALSE - Whether to use a persistent connection
| ['db_debug'] TRUE/FALSE - Whether database errors should be displayed.
| ['cache_on'] TRUE/FALSE - Enables/disables query caching
| ['cachedir'] The path to the folder where cache files should be stored
| ['char_set'] The character set used in communicating with the database
| ['dbcollat'] The character collation used in communicating with the database
| NOTE: For MySQL and MySQLi databases, this setting is only used
| as a backup if your server is running PHP < 5.2.3 or MySQL < 5.0.7
| (and in table creation queries made with DB Forge).
| There is an incompatibility in PHP with mysql_real_escape_string() which
| can make your site vulnerable to SQL injection if you are using a
| multi-byte character set and are running versions lower than these.
| Sites using Latin-1 or UTF-8 database character set and collation are unaffected.
| ['swap_pre'] A default table prefix that should be swapped with the dbprefix
| ['encrypt'] Whether or not to use an encrypted connection.
|
| 'mysql' (deprecated), 'sqlsrv' and 'pdo/sqlsrv' drivers accept TRUE/FALSE
| 'mysqli' and 'pdo/mysql' drivers accept an array with the following options:
|
| 'ssl_key' - Path to the private key file
| 'ssl_cert' - Path to the public key certificate file
| 'ssl_ca' - Path to the certificate authority file
| 'ssl_capath' - Path to a directory containing trusted CA certificates in PEM format
| 'ssl_cipher' - List of *allowed* ciphers to be used for the encryption, separated by colons (':')
| 'ssl_verify' - TRUE/FALSE; Whether verify the server certificate or not
|
| ['compress'] Whether or not to use client compression (MySQL only)
| ['stricton'] TRUE/FALSE - forces 'Strict Mode' connections
| - good for ensuring strict SQL while developing
| ['ssl_options'] Used to set various SSL options that can be used when making SSL connections.
| ['failover'] array - A array with 0 or more data for connections if the main should fail.
| ['save_queries'] TRUE/FALSE - Whether to "save" all executed queries.
| NOTE: Disabling this will also effectively disable both
| $this->db->last_query() and profiling of DB queries.
| When you run a query, with this setting set to TRUE (default),
| CodeIgniter will store the SQL statement for debugging purposes.
| However, this may cause high memory usage, especially if you run
| a lot of SQL queries ... disable this to avoid that problem.
|
| The $active_group variable lets you choose which connection group to
| make active. By default there is only one group (the 'default' group).
|
| The $query_builder variables lets you determine whether or not to load
| the query builder class.
*/
$active_group = 'default';
$query_builder = TRUE;
$db['default'] = array(
'dsn' => '',
'hostname' => app_env('DB_HOST', '127.0.0.1') ?: '127.0.0.1',
'port' => (int) (app_env('DB_PORT', 3306) ?: 3306),
'username' => app_env('DB_USERNAME', '') ?: '',
'password' => app_env('DB_PASSWORD', '') ?: '',
'database' => app_env('DB_DATABASE', '') ?: '',
'dbdriver' => 'mysqli', // WAJIB: driver PostgreSQL
'dbprefix' => '',
'pconnect' => FALSE,
'db_debug' => (ENVIRONMENT !== 'production'),
'cache_on' => FALSE,
'cachedir' => '',
'char_set' => 'utf8mb4',
'dbcollat' => 'utf8mb4_unicode_ci',
'swap_pre' => '',
'encrypt' => FALSE,
'compress' => FALSE,
'stricton' => TRUE,
'failover' => array(),
'save_queries' => (ENVIRONMENT !== 'production')
);
+1 -1
View File
@@ -11,7 +11,7 @@ defined('BASEPATH') OR exit('No direct script access allowed');
| and disable it back when you're done.
|
*/
$config['migration_enabled'] = FALSE;
$config['migration_enabled'] = TRUE;
/*
|--------------------------------------------------------------------------
+3
View File
@@ -76,6 +76,7 @@ return array(
'gif' => 'image/gif',
'jpeg' => array('image/jpeg', 'image/pjpeg'),
'jpg' => array('image/jpeg', 'image/pjpeg'),
'jfif' => array('image/jpeg', 'image/pjpeg'),
'jpe' => array('image/jpeg', 'image/pjpeg'),
'jp2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'),
'j2k' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'),
@@ -86,6 +87,8 @@ return array(
'mj2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'),
'mjp2' => array('image/jp2', 'video/mj2', 'image/jpx', 'image/jpm'),
'png' => array('image/png', 'image/x-png'),
'webp' => 'image/webp',
'avif' => 'image/avif',
'tiff' => 'image/tiff',
'tif' => 'image/tiff',
'heic' => 'image/heic',
+33
View File
@@ -0,0 +1,33 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
$config['permission_features'] = array(
'dashboard' => 'Dashboard', 'approvals' => 'Daftar Approval',
'invoices' => 'Penjualan, Invoice & Piutang', 'purchases' => 'Pembelian, Supplier & Hutang',
'items' => 'Persediaan & Gudang',
'technician_equipment' => 'Operasional Perangkat - Akses & Export',
'technician_equipment_all' => 'Operasional Perangkat - Lihat Seluruh Teknisi',
'technician_attachment' => 'Operasional Perangkat - Lihat Lampiran',
'technician_handover' => 'Operasional Perangkat - Serah Terima',
'technician_installation' => 'Operasional Perangkat - Pasang & Lepas',
'technician_transfer' => 'Operasional Perangkat - Transfer',
'technician_return' => 'Operasional Perangkat - Pengembalian',
'technician_inspection' => 'Operasional Perangkat - Pemeriksaan',
'technician_customer_item' => 'Operasional Perangkat - Barang Customer',
'technician_condition' => 'Operasional Perangkat - Rusak/Hilang/Nonaktif',
'technician_correction' => 'Operasional Perangkat - Koreksi',
'asset' => 'Aset Tetap',
'jurnal' => 'Jurnal & Periode Accounting',
'reports' => 'Laporan Profesional', 'budgets' => 'Budget',
'hr_organization' => 'HR - Departemen & Posisi',
'hr_employees' => 'HR - Data Karyawan',
'hr_schedules' => 'HR - Jadwal, Shift & Hari Libur',
'hr_attendance' => 'HR - Absensi & Pengajuan Manual',
'hr_leave' => 'HR - Cuti & Izin',
'payroll' => 'Payroll Control'
);
$config['permission_actions'] = array(
'can_view' => 'Lihat', 'can_create' => 'Buat', 'can_update' => 'Ubah',
'can_delete' => 'Hapus', 'can_submit' => 'Ajukan', 'can_approve' => 'Approve', 'can_post' => 'Posting',
'can_export' => 'Export', 'can_import' => 'Import'
);
+23 -1
View File
@@ -49,10 +49,21 @@ defined('BASEPATH') OR exit('No direct script access allowed');
| Examples: my-controller/index -> my_controller/index
| my-controller/my-method -> my_controller/my_method
*/
$route['default_controller'] = 'dashboard';
$route['default_controller'] = 'auth';
$route['404_override'] = '';
$route['translate_uri_dashes'] = FALSE;
$route['login'] = 'auth/index';
$route['logout'] = 'auth/logout';
$route['profile'] = 'profile/index/settings';
$route['profile/update-profile'] = 'profile/update_profile';
$route['profile/update-security'] = 'profile/update_security';
$route['profile/update-password'] = 'profile/update_password';
$route['profile/revoke-session/(:num)'] = 'profile/revoke_session/$1';
// Payroll profesional memakai satu controller utama; URL lama tetap membuka halaman yang sama.
$route['payroll'] = 'hrpayroll/index';
// untuk webhook absensi
$route['iclock/cdata'] = 'AttendanceWebhook/cdata';
@@ -63,3 +74,14 @@ $route['dbcompare/sql_download'] = 'Dbcompare/sql_download';
// teknisi log barang
$route['teknisi/log_barang'] = 'teknisi/log_barang';
// Invoice Supplier cukup diverifikasi melalui three-way matching; bukan approval.
$route['purchases/submit_bill/(:num)'] = 'purchases/verify_bill/$1';
$route['purchases/approve_bill/(:num)'] = 'purchases/verify_bill/$1';
// Proses pengecualian pembelian dipisahkan dari lima langkah workflow normal.
$route['purchases/returns/pdf/(:any)/(:num)'] = 'purchasereturns/pdf/$1/$2';
$route['purchases/returns/detail/(:any)/(:num)'] = 'purchasereturns/detail/$1/$2';
$route['purchases/returns/(:any)/(:num)'] = 'purchasereturns/$1/$2';
$route['purchases/returns/(:any)'] = 'purchasereturns/$1';
$route['purchases/returns'] = 'purchasereturns/index';
+27
View File
@@ -0,0 +1,27 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/*
* Daftar halaman yang membutuhkan library frontend tambahan.
* File ini dibaca terpisah oleh header dan footer karena setiap view
* CodeIgniter mempunyai scope variabelnya sendiri.
*/
$config['ui_data_pages'] = array(
'items', 'draft_items', 'item_historys', 'kode_barang',
'peralatan_teknisi', 'warehouses', 'customers', 'employees', 'shift',
'holidays', 'attendance_monitoring', 'attendance_monthly',
'leave_requests', 'generate_schedule', 'payroll', 'suppliers',
'purchases', 'payables', 'receivables', 'sales_documents', 'sales_invoice',
'invoice_draft', 'invoice_paid', 'jurnal', 'basejurnal', 'accounts',
'accounting_periods', 'inventory_professional', 'inventory_overview',
'inventory_operations', 'inventory_locations', 'inventory_reports', 'professional_reports',
'hr_payroll', 'fixed_assets', 'asset',
'lokasiasset', 'users', 'activity_logs', 'asset', 'history', 'pesan',
'kelas', 'kamar', 'siswa', 'attendance_monitoring',
'attendance_monthly', 'buku_besar'
);
$config['ui_select_pages'] = array_merge(
$config['ui_data_pages'],
array('budgets', 'companies')
);
@@ -0,0 +1,28 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Accountingattachments extends MY_Controller
{
private $imageTypes='jpg|jpeg|jpe|jfif|png|webp|gif|bmp|tif|tiff|heic|heif|avif|jp2|j2k|jpf|jpg2|jpx|ico';
public function upload(){
$type=$this->input->post('entity_type');$id=(int)$this->input->post('entity_id');$purchaseTypes=array('purchase_order','goods_receipt','supplier_invoice','supplier_payment','purchase_return','purchase_return_shipment','purchase_replacement','supplier_debit_note','purchase_refund','supplier_refund','supplier_refund_receipt');
if(!in_array($type,array_merge(array('asset','asset_maintenance','asset_opname','cash_transaction','cash_transfer','bank_reconciliation','stock_document','technician_equipment_document','customer_equipment_registry'),$purchaseTypes),true)||$id<1)return json_response(false,'Target lampiran tidak valid.',array(),422);
$this->assertEntityAccess((object)array('entity_type'=>$type,'entity_id'=>$id));
if($type==='asset')$this->requirePermission('asset','can_create');
if($type==='asset_maintenance')$this->requirePermission('asset','can_update');
if($type==='asset_opname')$this->requirePermission('asset','can_create');
if($type==='asset'&&!$this->db->get_where('assets',array('id'=>$id,'company_id'=>(int)$this->session->userdata('company_id'),'workflow_status'=>'draft'))->row())return json_response(false,'Lampiran perolehan hanya dapat ditambah saat draft.',array(),422);
if($type==='asset_opname'&&!$this->db->get_where('asset_opnames',array('id'=>$id,'company_id'=>(int)$this->session->userdata('company_id'),'status'=>'draft'))->row())return json_response(false,'Bukti opname hanya dapat ditambah saat draft.',array(),422);
if($type==='stock_document'){$this->assertEntityAccess((object)array('entity_type'=>$type,'entity_id'=>$id));$this->requirePermission('items','can_create');}
if(empty($_FILES['attachment']['tmp_name'])||!is_uploaded_file($_FILES['attachment']['tmp_name']))return json_response(false,'Pilih file gambar atau PDF.',array(),422);
$tmp=$_FILES['attachment']['tmp_name'];$mime=function_exists('finfo_open')?(new finfo(FILEINFO_MIME_TYPE))->file($tmp):($_FILES['attachment']['type']??'');
if($mime!=='application/pdf'&&strpos((string)$mime,'image/')!==0)return json_response(false,'Isi file bukan gambar atau PDF yang valid.',array(),422);
$dir=FCPATH.'uploads/accounting/';if(!is_dir($dir)&&!mkdir($dir,0750,true))return json_response(false,'Folder lampiran tidak tersedia.',array(),500);
$this->load->library('upload',array('upload_path'=>$dir,'allowed_types'=>'pdf|'.$this->imageTypes,'max_size'=>5120,'encrypt_name'=>true));
if(!$this->upload->do_upload('attachment'))return json_response(false,'File '.$_FILES['attachment']['name'].' ditolak: '.strip_tags($this->upload->display_errors()),array(),422);
$f=$this->upload->data();$module=in_array($type,$purchaseTypes,true)?'purchase':($type==='stock_document'?'inventory':(strpos($type,'asset')===0?'fixed_asset':'cash_bank'));$row=array('module'=>$module,'entity_type'=>$type,'entity_id'=>$id,'original_name'=>$f['orig_name'],'stored_name'=>$f['file_name'],'mime_type'=>$f['file_type'],'file_size'=>$f['file_size']*1024,'sha256'=>hash_file('sha256',$f['full_path']),'uploaded_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s'));if($this->db->field_exists('company_id','transaction_attachments'))$row['company_id']=(int)$this->session->userdata('company_id');$this->db->insert('transaction_attachments',$row);return json_response(true,'Lampiran berhasil disimpan.',array('id'=>$this->db->insert_id()));
}
public function download($id){$f=$this->db->get_where('transaction_attachments',array('id'=>(int)$id))->row();if(!$f)show_404();$this->assertEntityAccess($f);$path=FCPATH.'uploads/accounting/'.$f->stored_name;if(!$this->safePath($path))show_404();$this->load->helper('download');force_download($f->original_name,file_get_contents($path));}
public function preview($id){$f=$this->db->get_where('transaction_attachments',array('id'=>(int)$id))->row();if(!$f)show_404();$this->assertEntityAccess($f);$path=FCPATH.'uploads/accounting/'.$f->stored_name;if(!$this->safePath($path))show_404();$mime=function_exists('finfo_open')?(new finfo(FILEINFO_MIME_TYPE))->file($path):$f->mime_type;if($mime!=='application/pdf'&&strpos((string)$mime,'image/')!==0)show_404();$name=str_replace(array('"',"\r","\n"),'',basename($f->original_name));$this->output->set_header('Content-Type: '.$mime)->set_header('Content-Disposition: inline; filename="'.$name.'"')->set_header('Content-Length: '.filesize($path))->set_header('X-Content-Type-Options: nosniff')->set_output(file_get_contents($path));}
private function assertEntityAccess($file){$company=(int)$this->session->userdata('company_id');if(in_array($file->entity_type,array('technician_equipment_document','customer_equipment_registry'),true)){$table=$file->entity_type==='technician_equipment_document'?'technician_equipment_documents':'customer_equipment_registry';$owned=$this->db->get_where($table,array('id'=>(int)$file->entity_id,'company_id'=>$company))->row();if(!$owned)show_error('Akses lampiran ditolak.',403,'Akses ditolak');$this->requirePermission('technician_attachment','can_view');return;}if($file->entity_type==='technician_installation'){$owned=$this->db->select('b.id')->from('item_barcodes b')->join('items i','i.id=b.item_id')->where(array('b.id'=>(int)$file->entity_id,'i.company_id'=>$company))->get()->row();if(!$owned)show_error('Akses lampiran ditolak.',403,'Akses ditolak');$this->requirePermission('technician_attachment','can_view');return;}if($file->entity_type==='technician_status_change'){$owned=$this->db->get_where('technician_item_status_logs',array('id'=>(int)$file->entity_id,'company_id'=>$company))->row();if(!$owned)show_error('Akses lampiran ditolak.',403,'Akses ditolak');$this->requirePermission('technician_attachment','can_view');return;}$map=array('stock_document'=>array('stock_documents','items'),'asset'=>array('assets','asset'),'asset_maintenance'=>array('asset_maintenance','asset'),'asset_opname'=>array('asset_opnames','asset'));if(!isset($map[$file->entity_type]))return;list($table,$feature)=$map[$file->entity_type];if(!$this->db->get_where($table,array('id'=>(int)$file->entity_id,'company_id'=>$company))->row())show_error('Akses lampiran ditolak.',403,'Akses ditolak');$this->requirePermission($feature,'can_view');}
private function safePath($path){$root=realpath(FCPATH.'uploads/accounting');$real=realpath($path);return$root&&$real&&strpos($real,$root.DIRECTORY_SEPARATOR)===0&&is_file($real);}
}
+158
View File
@@ -0,0 +1,158 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Pemeriksaan read-only fondasi accounting untuk deployment dan CI. */
class Accountingcheck extends CI_Controller
{
public function __construct()
{
parent::__construct();
if (!$this->input->is_cli_request()) {
show_error('Accounting check hanya dapat dijalankan melalui CLI.', 403, 'Akses ditolak');
}
}
public function index()
{
$failures = array();
foreach (array('journals', 'journal_details', 'accounts', 'document_sequences', 'system_account_mappings', 'fiscal_years', 'accounting_periods', 'journal_status_histories') as $table) {
if (!$this->db->table_exists($table)) {
$failures[] = 'Tabel tidak ditemukan: ' . $table;
}
}
if (empty($failures)) {
try {
$this->load->library('AccountMappingService');
$this->load->library('JournalService');
foreach (array(
'inventory', 'cost_of_goods_sold', 'accounts_receivable', 'deferred_revenue',
'inventory_in_sales', 'goods_revenue', 'customer_savings', 'depreciation_expense'
) as $mappingKey) {
$this->accountmappingservice->get($mappingKey);
}
$testTotals = $this->journalservice->totals(array(
array('debit' => 100, 'kredit' => 0),
array('debit' => 0, 'kredit' => 100)
));
if ($testTotals['debit'] !== 100.0 || $testTotals['kredit'] !== 100.0) {
$failures[] = 'JournalService gagal menghitung total jurnal.';
}
} catch (Throwable $exception) {
$failures[] = 'Service foundation gagal dimuat: ' . $exception->getMessage();
}
$invalidMappings = $this->db->query(
'SELECT m.mapping_key FROM system_account_mappings m '
. 'LEFT JOIN accounts a ON a.id = m.account_id '
. 'WHERE m.is_active = 1 AND (a.id IS NULL OR a.is_active <> 1 OR a.allow_posting <> 1)'
)->result();
foreach ($invalidMappings as $mapping) {
$failures[] = 'Mapping akun tidak valid: ' . $mapping->mapping_key;
}
$duplicates = $this->db->query(
"SELECT no_ref, COUNT(*) total FROM journals WHERE no_ref IS NOT NULL AND no_ref <> '' "
. 'GROUP BY no_ref HAVING COUNT(*) > 1 LIMIT 20'
)->result();
foreach ($duplicates as $duplicate) {
$failures[] = 'Nomor jurnal duplikat: ' . $duplicate->no_ref;
}
$unbalanced = $this->db->query(
'SELECT j.id, j.no_ref FROM journals j '
. 'JOIN journal_details d ON d.journal_id = j.id '
. 'GROUP BY j.id, j.no_ref '
. 'HAVING ABS(SUM(d.debit) - SUM(d.kredit)) > 0.009 LIMIT 20'
)->result();
foreach ($unbalanced as $journal) {
$failures[] = 'Jurnal tidak balance: ' . $journal->no_ref . ' (ID ' . $journal->id . ')';
}
$missingPeriods = $this->db->where('accounting_period_id IS NULL', null, false)->count_all_results('journals');
if ($missingPeriods > 0) {
$failures[] = $missingPeriods . ' jurnal belum terhubung ke periode accounting.';
}
}
if (!empty($failures)) {
foreach ($failures as $failure) {
fwrite(STDERR, '[FAIL] ' . $failure . PHP_EOL);
}
exit(1);
}
echo '[OK] Tabel fondasi, mapping akun, nomor referensi, dan keseimbangan jurnal valid.' . PHP_EOL;
}
public function schema()
{
foreach (array('journals', 'journal_details', 'accounts', 'roles', 'users') as $table) {
echo '--- ' . $table . ' ---' . PHP_EOL;
foreach ($this->db->field_data($table) as $field) {
echo implode(' | ', array(
$field->name,
$field->type,
isset($field->max_length) ? $field->max_length : '',
!empty($field->primary_key) ? 'PK' : '',
isset($field->default) ? 'default=' . $field->default : ''
)) . PHP_EOL;
}
}
$stats = $this->db->select('COUNT(*) total, MIN(tanggal) min_date, MAX(tanggal) max_date', false)->get('journals')->row();
echo 'journals=' . $stats->total . ' range=' . $stats->min_date . '..' . $stats->max_date . PHP_EOL;
echo '--- journal status ---' . PHP_EOL;
foreach ($this->db->select('status, COUNT(*) total', false)->group_by('status')->get('journals')->result() as $row) {
echo $row->status . '=' . $row->total . PHP_EOL;
}
echo '--- column definitions ---' . PHP_EOL;
$columns = $this->db->query(
"SELECT TABLE_NAME, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE FROM information_schema.COLUMNS "
. "WHERE TABLE_SCHEMA = DATABASE() AND ((TABLE_NAME = 'journals' AND COLUMN_NAME = 'status') "
. "OR (TABLE_NAME = 'accounts' AND COLUMN_NAME IN ('tipe','posisi','kategori','sub_tipe')))"
)->result();
foreach ($columns as $column) {
echo $column->TABLE_NAME . '.' . $column->COLUMN_NAME . '=' . $column->COLUMN_TYPE . ' nullable=' . $column->IS_NULLABLE . PHP_EOL;
}
echo '--- duplicate account codes ---' . PHP_EOL;
$duplicates = $this->db->select('kode_akun, COUNT(*) total', false)->group_by('kode_akun')->having('COUNT(*) >', 1)->get('accounts')->result();
echo count($duplicates) . PHP_EOL;
echo '--- roles ---' . PHP_EOL;
foreach ($this->db->order_by('id', 'ASC')->get('roles')->result() as $role) {
echo $role->id . '=' . $role->nama_role . PHP_EOL;
}
echo '--- users per role ---' . PHP_EOL;
foreach ($this->db->select('roles.nama_role, COUNT(users.id) total')->from('roles')->join('users', 'users.role_id = roles.id', 'left')->group_by('roles.id')->get()->result() as $role) {
echo $role->nama_role . '=' . $role->total . PHP_EOL;
}
}
public function periodtrigger()
{
$this->db->trans_begin();
try {
$reference = 'CHECK-' . date('YmdHis') . '-' . mt_rand(1000, 9999);
$this->db->insert('journals', array(
'tanggal' => date('Y-m-d'),
'no_ref' => $reference,
'keterangan' => 'Temporary period trigger check',
'created_by' => 1,
'status' => 'posted'
));
$row = $this->db->get_where('journals', array('id' => $this->db->insert_id()))->row();
if (!$row || !$row->accounting_period_id || !$row->posted_at) {
throw new RuntimeException('Trigger tidak mengisi periode atau metadata posting.');
}
echo '[OK] Period trigger mengisi accounting_period_id dan metadata posted.' . PHP_EOL;
} catch (Throwable $exception) {
fwrite(STDERR, '[FAIL] ' . $exception->getMessage() . PHP_EOL);
$this->db->trans_rollback();
exit(1);
}
$this->db->trans_rollback();
}
}
@@ -0,0 +1,78 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Accountingperiods extends MY_Controller
{
public function __construct()
{
parent::__construct();
$this->requirePermission('jurnal');
$this->load->model('FiscalPeriodModel', 'fiscalPeriodModel');
$this->load->library('FiscalPeriodService');
}
public function index()
{
$data = array('active_menu' => 'accounting_periods');
$this->load->view('partials/header', $data);
$this->load->view('accounting_periods/layout');
$this->load->view('partials/footer');
}
public function get_data()
{
$rows = array();
foreach ($this->fiscalPeriodModel->allWithFiscalYear() as $period) {
$class = array('open' => 'success', 'soft_closed' => 'warning', 'closed' => 'danger');
$actions = '<button class="btn btn-sm btn-outline-success btn-period-status" data-id="'.$period->id.'" data-status="open">Open</button> '
. '<button class="btn btn-sm btn-outline-warning btn-period-status" data-id="'.$period->id.'" data-status="soft_closed">Soft Close</button> '
. '<button class="btn btn-sm btn-outline-danger btn-period-status" data-id="'.$period->id.'" data-status="closed">Close</button>';
$rows[] = array(
$period->fiscal_year_name,
$period->period_number,
$period->name,
$period->start_date,
$period->end_date,
'<span class="badge bg-'.$class[$period->status].'">'.strtoupper(str_replace('_', ' ', $period->status)).'</span>',
$actions
);
}
return json_response(true, 'Data periode berhasil dimuat.', array('data' => $rows));
}
public function change_status($id)
{
if (strtoupper($this->input->method()) !== 'POST') {
return json_response(false, 'Metode request tidak diizinkan.', array(), 405);
}
try {
$period = $this->fiscalperiodservice->changeStatus(
$id,
$this->input->post('status', true),
$this->session->userdata('user_id'),
$this->input->post('reason', true)
);
log_activity('Accounting Period', 'change_status', 'Periode '.$period->name.' menjadi '.$period->status, 'success');
return json_response(true, 'Status periode berhasil diperbarui.', array('period' => $period));
} catch (Throwable $exception) {
return business_exception_response($exception);
}
}
public function create_year()
{
if (strtoupper($this->input->method()) !== 'POST') {
return json_response(false, 'Metode request tidak diizinkan.', array(), 405);
}
try {
$id = $this->fiscalperiodservice->createCalendarYear(
$this->input->post('year', true),
$this->session->userdata('user_id')
);
log_activity('Fiscal Year', 'create', 'Membuat tahun buku ID '.$id, 'success');
return json_response(true, 'Tahun buku dan 12 periode berhasil dibuat.', array('fiscal_year_id' => $id));
} catch (Throwable $exception) {
return business_exception_response($exception);
}
}
}
@@ -0,0 +1,27 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Accountingrepair extends CI_Controller
{
public function __construct() { parent::__construct(); if(!is_cli()) show_404(); $this->load->library('AuditService'); }
public function duplicate_refs()
{
if (strtolower((string)app_env('APP_ENV', ENVIRONMENT)) === 'production') { echo "Ditolak: repair ini hanya untuk development.\n"; return; }
$groups=$this->db->select('no_ref, COUNT(*) total')->from('journals')->group_by('no_ref')->having('COUNT(*) >',1)->get()->result();
$changed=0; $this->db->trans_begin();
try {
foreach($groups as $group) {
$rows=$this->db->query('SELECT id,no_ref FROM journals WHERE no_ref=? ORDER BY id ASC FOR UPDATE',array($group->no_ref))->result();
array_shift($rows);
foreach($rows as $row) {
$suffix='-D'.$row->id; $new=substr($row->no_ref,0,50-strlen($suffix)).$suffix;
$this->db->where('id',$row->id)->update('journals',array('no_ref'=>$new));
$this->auditservice->record('development_repair','journal',$row->id,'renumber_duplicate_reference',array('no_ref'=>$row->no_ref),array('no_ref'=>$new),null);
echo $row->no_ref.' -> '.$new."\n"; $changed++;
}
}
if(!$this->db->trans_status()) throw new RuntimeException('Repair database gagal.');
$this->db->trans_commit(); echo "Selesai: {$changed} referensi diperbaiki.\n";
} catch(Throwable $e) { $this->db->trans_rollback(); echo 'Gagal: '.$e->getMessage()."\n"; }
}
}
+25 -81
View File
@@ -1,12 +1,14 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Accounts extends CI_Controller {
class Accounts extends MY_Controller {
public function __construct()
{
parent::__construct();
$this->requirePermission('jurnal');
$this->load->model('DynamicModel', 'dm');
$this->load->library('ChartOfAccountsService');
if (!$this->session->userdata('logged_in')) {
redirect('auth');
@@ -34,7 +36,7 @@ class Accounts extends CI_Controller {
{
$this->dm->setTable('accounts')
->setColumn(
[null,'kode_akun','nama_akun','tipe','posisi','kategori',null],
[null,'kode_akun','priority','nama_akun','tipe','posisi','kategori','is_header','is_hidden','is_active',null],
['kode_akun','nama_akun','tipe'],
['kode_akun'=>'ASC']
);
@@ -55,6 +57,9 @@ class Accounts extends CI_Controller {
ucfirst($row->tipe),
ucfirst($row->posisi),
ucfirst($row->kategori),
$row->is_header ? 'Header' : 'Transaksi',
$row->is_hidden ? '<span class="badge bg-warning text-dark">Disembunyikan</span>' : '<span class="badge bg-info text-dark">Ditampilkan</span>',
$row->is_active ? '<span class="badge bg-success">Aktif</span>' : '<span class="badge bg-secondary">Nonaktif</span>',
'
<button class="btn btn-sm btn-warning btn-edit" data-id="'.$row->id.'">Edit</button>
<button class="btn btn-sm btn-danger btn-delete" data-id="'.$row->id.'">Delete</button>
@@ -73,64 +78,21 @@ class Accounts extends CI_Controller {
// ================= SAVE
public function save()
{
$tipe = $this->input->post('tipe');
$data = [
'kode_akun' => $this->input->post('kode_akun'),
'nama_akun' => $this->input->post('nama_akun'),
'tipe' => $tipe,
'posisi' => in_array($tipe, ['asset','expense']) ? 'debit' : 'kredit',
'kategori' => in_array($tipe, ['asset','liability','equity']) ? 'neraca' : 'laba_rugi',
'parent_id' => $this->input->post('parent_id') ?: NULL,
'priority' => $this->input->post('priority') ?: NULL
];
$insert = $this->dm->setTable('accounts')->insert($data);
if($insert){
log_activity(
'Accounts',
'create',
'Menambah akun: '.$data['kode_akun'].' - '.$data['nama_akun']
);
}
echo json_encode([
'status' => $insert ? true : false,
'message' => $insert ? 'Data berhasil disimpan' : 'Gagal simpan'
]);
try {
$id = $this->chartofaccountsservice->save($this->input->post(), null, $this->session->userdata('user_id'));
log_activity('Accounts', 'create', 'Menambah akun ID '.$id);
return json_response(true, 'Akun berhasil disimpan.', array('id' => $id));
} catch (Throwable $exception) { return business_exception_response($exception); }
}
// ================= UPDATE
public function update()
{
$id = $this->input->post('id');
$tipe = $this->input->post('tipe');
$data = [
'kode_akun' => $this->input->post('kode_akun'),
'nama_akun' => $this->input->post('nama_akun'),
'tipe' => $tipe,
'posisi' => in_array($tipe, ['asset','expense']) ? 'debit' : 'kredit',
'kategori' => in_array($tipe, ['asset','liability','equity']) ? 'neraca' : 'laba_rugi',
'parent_id' => $this->input->post('parent_id') ?: NULL,
'priority' => $this->input->post('priority') ?: NULL
];
$update = $this->dm->setTable('accounts')->update($id, $data);
if($update){
log_activity(
'Accounts',
'update',
'Update akun ID '.$id.' menjadi: '.$data['kode_akun'].' - '.$data['nama_akun']
);
}
echo json_encode([
'status' => $update ? true : false,
'message' => $update ? 'Data berhasil diupdate' : 'Gagal update'
]);
try {
$id = $this->chartofaccountsservice->save($this->input->post(), $this->input->post('id'), $this->session->userdata('user_id'));
log_activity('Accounts', 'update', 'Memperbarui akun ID '.$id);
return json_response(true, 'Akun berhasil diperbarui.', array('id' => $id));
} catch (Throwable $exception) { return business_exception_response($exception); }
}
public function detail($id)
@@ -141,31 +103,13 @@ class Accounts extends CI_Controller {
public function delete($id)
{
// cek apakah jadi parent
$child = $this->db->where('parent_id',$id)->count_all_results('accounts');
if($child > 0){
echo json_encode([
'status'=>false,
'message'=>'Tidak bisa hapus, akun punya child'
]);
return;
if (strtoupper($this->input->method()) !== 'POST') {
return json_response(false, 'Metode request tidak diizinkan.', array(), 405);
}
$delete = $this->dm->setTable('accounts')->delete($id);
if($delete){
log_activity(
'Accounts',
'delete',
'Menghapus akun ID '.$id,
'success'
);
}
echo json_encode([
'status' => $delete ? true : false,
'message' => $delete ? 'Data berhasil dihapus' : 'Gagal hapus'
]);
try {
$this->chartofaccountsservice->delete($id, $this->session->userdata('user_id'));
log_activity('Accounts', 'delete', 'Menghapus akun ID '.$id, 'success');
return json_response(true, 'Akun berhasil dihapus.');
} catch (Throwable $exception) { return business_exception_response($exception); }
}
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Approvals extends MY_Controller
{
public function __construct() { parent::__construct(); if(!$this->session->userdata('logged_in')) redirect('auth'); $this->load->library('ApprovalService'); }
public function index()
{
$userId=(int)$this->session->userdata('user_id'); $roleId=(int)$this->session->userdata('role_id');
if(!$roleId){$u=$this->db->select('role_id')->get_where('users',array('id'=>$userId))->row();$roleId=$u?(int)$u->role_id:0;}
$data=array('active_menu'=>'approvals','requests'=>$this->approvalservice->pendingFor($userId,$roleId));
$this->load->view('partials/header',$data);$this->load->view('approvals/index',$data);$this->load->view('partials/footer');
}
}
+11 -500
View File
@@ -1,505 +1,16 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Asset extends CI_Controller {
public function __construct(){
parent::__construct();
$this->load->database();
if (!$this->session->userdata('logged_in')) {
redirect('auth');
}
}
public function index(){
$data = ["active_menu" => "asset"];
$this->load->view('partials/header', $data);
$this->load->view('asset/layout');
$this->load->view('partials/footer');
}
// ================= GET DATA =================
public function get_data(){
$data = $this->db
->select('assets.*, lokasi_asset.nama as nama_lokasi, item_barcodes.barcode as barcode, item_barcodes.id as barcode_id')
->from('assets')
->join('lokasi_asset', 'lokasi_asset.id = assets.lokasi_asset_id', 'left')
->join('item_barcodes', 'item_barcodes.id = assets.barcode_id', 'left')
->order_by('assets.tanggal_perolehan','DESC')
->get()
->result();
$no = 1;
$result = [];
foreach($data as $d){
$result[] = [
$no++,
$d->tanggal_perolehan,
$d->kode_asset ?? '-',
$d->nama_asset . ($d->barcode_id ? '<br><small class="text-muted"><i>'.$d->barcode.'</i></small>' : ''),
$d->nama_lokasi ?? '-',
$d->keterangan ?? '-',
number_format($d->nilai_perolehan),
$d->masa_manfaat . ' bln',
number_format($d->penyusutan_per_bulan),
$d->created_at,
'
<button class="btn btn-sm btn-info btn-detail mb-1 ml-1" data-id="'.$d->id.'"><i class="bi bi-eye"></i></button>
<button class="btn btn-sm btn-warning btn-edit mb-1 ml-1" data-id="'.$d->id.'">Edit</button>
<button class="btn btn-sm btn-danger btn-delete mb-1 ml-1" data-id="'.$d->id.'">Hapus</button>
'
];
}
echo json_encode(['data'=>$result]);
}
// ================= DETAIL ASSET + HISTORY =================
public function detail($id)
/**
* Alias kompatibilitas. Seluruh proses Aset Tetap sekarang hanya dikelola
* Fixedassets agar tidak ada dua implementasi stok/jurnal yang berbeda.
*/
class Asset extends MY_Controller
{
public function index(){ redirect('fixedassets', 'location', 301); }
public function __call($method,$arguments)
{
$data = $this->db
->select('a.*, la.nama AS nama_lokasi') // ambil field tambahan dari lokasi_asset
->from('assets a')
->join('lokasi_asset la', 'a.lokasi_asset_id = la.id', 'left')
->where('a.id', $id)
->get()
->row();
echo json_encode($data);
if(strtoupper($this->input->method())==='GET') redirect('fixedassets', 'location', 302);
return json_response(false,'Endpoint Aset lama sudah dinonaktifkan. Gunakan menu Aset Tetap.',array(),410);
}
public function get_history($asset_id){
$data = $this->db
->where('asset_id', $asset_id)
->order_by('created_at', 'DESC')
->get('asset_mutations')
->result();
echo json_encode($data);
}
// ================= SAVE MANUAL (1 UNIT) =================
public function save(){
$harga = preg_replace('/\D/','',$this->input->post('harga'));
$residu = preg_replace('/\D/','',$this->input->post('residu'));
$masa = (int)$this->input->post('masa');
$account_debit_id = (int)$this->input->post('account_asset');
$account_kredit_id = (int)$this->input->post('account_kas');
$lokasi_asset_id = (int)$this->input->post('lokasi_asset');
$tanggal = $this->input->post('tanggal');
$nama_asset = $this->input->post('nama_asset');
$keterangan = $this->input->post('keterangan');
$nilai_perolehan = $harga * 1;
$penyusutan = ($harga - $residu) / ($masa ?: 1);
$this->db->trans_start();
$data = [
'kode_asset' => 'AST-'.time(),
'nama_asset' => $nama_asset,
'harga_per_unit' => $harga,
'nilai_perolehan' => $nilai_perolehan,
'tanggal_perolehan' => $tanggal,
'masa_manfaat' => $masa,
'nilai_residu' => $residu,
'penyusutan_per_bulan' => $penyusutan,
'nilai_buku' => $nilai_perolehan,
'sumber' => 'pembelian',
'account_debit_id' => $account_debit_id,
'account_kredit_id' => $account_kredit_id,
'lokasi_asset_id' => $lokasi_asset_id,
'keterangan' => $keterangan,
'qty' => 1
];
$this->db->insert('assets',$data);
$asset_id = $this->db->insert_id();
// Journal
$this->db->insert('journals',[
'tanggal' => $tanggal,
'no_ref' => 'AST-'.date('YmdHis').'-'.$asset_id,
'keterangan' => 'Penambahan Aset '.$nama_asset,
'ref_type' => 'assets',
'ref_id' => $asset_id,
'created_by' => $this->session->userdata('user_id')
]);
$jid = $this->db->insert_id();
$this->db->insert('journal_details',[
'journal_id' => $jid,
'account_id' => $account_debit_id,
'debit' => $nilai_perolehan,
'kredit' => 0
]);
$this->db->insert('journal_details',[
'journal_id' => $jid,
'account_id' => $account_kredit_id,
'debit' => 0,
'kredit' => $nilai_perolehan
]);
// MUTATION PEROLEHAN
$this->db->insert('asset_mutations',[
'asset_id' => $asset_id,
'tipe' => 'perolehan',
'nilai' => $nilai_perolehan,
'keterangan' => 'Perolehan manual: '.$keterangan,
'tanggal_dibuat' => $tanggal
]);
$this->db->trans_complete();
if ($this->db->trans_status()) {
log_activity('Asset', 'create', 'Menambah asset manual: '.$nama_asset.' (ID: '.$asset_id.')');
echo json_encode(['status'=>true]);
} else {
echo json_encode(['status'=>false]);
}
}
// ================= SAVE FROM STOCK =================
public function save_from_stock(){
$item_id = $this->input->post('item_id');
$account_debit_id = (int)$this->input->post('account_asset');
$lokasi_asset_id = (int)$this->input->post('s_lokasi_asset');
$masa = (int)$this->input->post('s_masa');
$residu = preg_replace('/\D/','',$this->input->post('s_residu'));
$tanggal = $this->input->post('s_tanggal');
$keterangan = $this->input->post('s_keterangan');
$barcode_id = $this->input->post('barcode_id');
$qty = (int)$this->input->post('qty');
if($qty < 1){
$qty = 1;
}
$item = $this->db->get_where('items',['id'=>$item_id])->row();
if(!$item){
echo json_encode(['status'=>false,'message'=>'Item tidak ditemukan']);
return;
}
if(!$barcode_id){
echo json_encode(['status'=>false,'message'=>'Barcode wajib dipilih']);
return;
}
if($item->stok < $qty){
echo json_encode(['status'=>false,'message'=>'Stok tidak cukup']);
return;
}
$harga = $item->harga_beli;
$nama_asset = $item->nama_barang;
$nilai_perolehan = $harga * $qty;
$penyusutan = ($nilai_perolehan - $residu) / ($masa ?: 1);
$this->db->trans_start();
// stock log keluar per item
$this->db->insert('stock_logs',[
'item_id' => $item_id,
'warehouse_id' => '1', // default gudang
'qty' => $qty,
'tipe' => 'keluar',
'keterangan' => 'Pemindahan barang ke asset tanggal ' . $tanggal . ' - (' . $keterangan . ')'
]);
$stock_log_id = $this->db->insert_id();
$this->update_item_stok($item_id);
// INSERT ASSET
$this->db->insert('assets',[
'item_id' => $item_id,
'barcode_id' => $barcode_id,
'stock_log_id' => $stock_log_id,
'kode_asset' => 'AST-'.time(),
'nama_asset' => $nama_asset,
'harga_per_unit' => $harga,
'nilai_perolehan' => $nilai_perolehan,
'tanggal_perolehan' => $tanggal,
'masa_manfaat' => $masa,
'nilai_residu' => $residu,
'penyusutan_per_bulan' => $penyusutan,
'nilai_buku' => $nilai_perolehan,
'sumber' => 'gudang',
'account_debit_id' => $account_debit_id,
'account_kredit_id' => 21, // Persediaan
'lokasi_asset_id' => $lokasi_asset_id,
'keterangan' => $keterangan,
'qty' => $qty
]);
$asset_id = $this->db->insert_id();
// START - Item Movement & item barcodes update status
$this->db->where('item_id', $item_id);
$this->db->order_by('created_at', 'DESC');
$this->db->limit(1);
$item_move = $this->db->get('item_movements')->row();
// get item_barcode
$itemBar = $this->db->get_where('item_barcodes', ['id'=>$barcode_id])->row();
if($itemBar->qty_sisa < $qty) {
echo json_encode(['status'=>false, 'message'=>'Stok tidak cukup. Sisa: '.$itemBar->qty_sisa]);
$this->db->trans_rollback();
return;
}
// END - Item Movement & item barcodes update status
$total_sisa = $itemBar->qty_sisa - $qty;
$status_bar = ($total_sisa == 0) ? 'installed' : 'available';
$this->db->insert('item_movements',[
'item_id' => $item_id,
'barcode_id' => $barcode_id,
'qty' => $qty,
'from_type' => $item_move ? $item_move->to_type : 'warehouse', //enum('supplier','warehouse','technician','customer','invoice','asset')
'to_type' => 'asset', //enum('supplier','warehouse','technician','customer','invoice','asset')
'movement_type' => 'installation', //enum('purchase','transfer','installation','uninstallation','supplier_return','customer_return','adjustment','sold_out','asset')
'notes' => $keterangan,
'created_at' => date('Y-m-d H:i:s')
]);
// Status yang boleh masuk : enum('available','reserved','installed','damaged','lost','returned_supplier','sold_out','asset')
$this->db->where('id', $barcode_id)->update('item_barcodes', ['status' => $status_bar, 'qty_sisa' => $total_sisa]);
// Journal
$this->db->insert('journals',[
'tanggal' => $tanggal,
'no_ref' => 'AST-'.date('YmdHis').'-'.$asset_id,
'keterangan' => 'Penambahan Aset dari Gudang '.$nama_asset,
'ref_type' => 'assets',
'ref_id' => $asset_id,
'created_by' => $this->session->userdata('user_id')
]);
$jid = $this->db->insert_id();
// Debit Asset
$this->db->insert('journal_details',[
'journal_id' => $jid,
'account_id' => $account_debit_id,
'debit' => $nilai_perolehan,
'kredit' => 0
]);
// Kredit Persediaan
$this->db->insert('journal_details',[
'journal_id' => $jid,
'account_id' => 21,
'debit' => 0,
'kredit' => $nilai_perolehan
]);
// MUTATION DARI GUDANG
$this->db->insert('asset_mutations',[
'asset_id' => $asset_id,
'tipe' => 'perolehan',
'nilai' => $nilai_perolehan,
'keterangan' => 'Perolehan dari gudang: '.$nama_asset,
'tanggal_dibuat' => $tanggal
]);
$this->db->trans_complete();
if ($this->db->trans_status()) {
log_activity('Asset', 'create', 'Menambah asset dari gudang: '.$nama_asset.' (ID: '.$asset_id.', Qty: '.$qty.')');
echo json_encode(['status'=>true]);
} else {
echo json_encode(['status'=>false]);
}
}
// ================= UPDATE =================
public function update(){
$id = $this->input->post('id');
$nama_asset = $this->input->post('nama_asset');
$lokasi_asset_id = (int)$this->input->post('lokasi_asset');
$keterangan = $this->input->post('keterangan');
$asset = $this->db->get_where('assets',['id'=>$id])->row();
if(!$asset){
echo json_encode(['status'=>false,'message'=>'Asset tidak ditemukan']);
return;
}
$data = [
'nama_asset' => $nama_asset,
'lokasi_asset_id' => $lokasi_asset_id,
'keterangan' => $keterangan
];
if($asset->barcode_id && $asset->item_id){
$data = [
'lokasi_asset_id' => $lokasi_asset_id,
'keterangan' => $keterangan
];
}
$this->db->trans_start();
$this->db->where('id',$id);
$this->db->update('assets',$data);
$this->db->trans_complete();
if ($this->db->trans_status()) {
log_activity('Asset', 'update', 'Update asset ID: '.$id.' Nama: '.$nama_asset);
echo json_encode(['status'=>true]);
} else {
echo json_encode(['status'=>false]);
}
}
// ================= DELETE =================
public function delete(){
$id = $this->input->post('id');
$alasan = $this->input->post('alasan');
$keterangan = $this->input->post('keterangan');
$asset = $this->db->get_where('assets',['id'=>$id])->row();
$itemBar = $this->db->get_where('item_barcodes',['id'=>$asset->barcode_id])->row();
if($alasan == 'dijual' || $alasan == 'rusak' ){
echo json_encode(['status'=>false,'message'=>'Alasan <b>' .strtoupper($alasan).'</b> tidak bisa digunakan, Karena fitur belum tersedia. Silahkan pilih alasan lain.']);
return;
}
$this->db->trans_start();
if ($asset->barcode_id && $asset->item_id) {
// Update item_barcodes status to 'available' if it was linked to this asset
$qty_sisa = $asset->qty + $itemBar->qty_sisa;
$this->db->where('id', $asset->barcode_id)
->update('item_barcodes', ['status' => 'available', 'qty_sisa' => $qty_sisa]);
$this->db->delete('stock_logs', ['id'=>$asset->stock_log_id]);
$this->update_item_stok($asset->item_id);
}
// Hapus journal
$journals = $this->db->get_where('journals', ['ref_type'=>'assets', 'ref_id'=>$id])->result();
foreach($journals as $j){
$this->db->delete('journal_details', ['journal_id'=>$j->id]);
$this->db->delete('journals', ['id'=>$j->id]);
}
// Hapus asset
$this->db->delete('assets',['id'=>$id]);
// MUTATION DELETE
$this->db->insert('asset_mutations',[
'asset_id' => $id,
'tipe' => 'pengurangan',
'nilai' => $asset->nilai_buku,
'keterangan' => 'Penghapusan ('.$alasan.'): '.$keterangan
]);
$this->db->trans_complete();
if ($this->db->trans_status()) {
log_activity('Asset', 'delete', 'Hapus asset ID: '.$id.' Alasan: '.$alasan);
echo json_encode(['status'=>true]);
} else {
echo json_encode(['status'=>false]);
}
}
// ================= GET DATA SELECT =================
public function get_accounts_kas(){
$data = $this->db
->where_in('sub_tipe', ['kas', 'hutang'])
->order_by('kode_akun', 'ASC')
->get('accounts')
->result();
echo json_encode($data);
}
public function get_accounts_asset(){
$data = $this->db
->group_start()
->like('nama_akun', 'Aset', 'after')
->group_end()
->order_by('kode_akun', 'ASC')
->get('accounts')
->result();
echo json_encode($data);
}
public function get_items()
{
// $data = $this->db
// ->select('items.*, kode_barang.*')
// ->from('items')
// ->join('kode_barang', 'items.kode_id = kode_barang.id', 'left')
// ->where('items.stok >', 0)
// ->get()
// ->result();
$this->db->select('
items.id,
items.nama_barang,
items.harga_jual,
items.harga_beli,
items.kode_detail,
kode_barang.tracking_type,
COALESCE(SUM(
stock_logs.qty * IF(stock_logs.tipe="masuk",1,-1)
),0) as stok
');
$this->db->where('status', 'active');
$this->db->from('items');
$this->db->join('stock_logs',
'stock_logs.item_id = items.id' ,
'left');
$this->db->join('kode_barang',
'items.kode_id = kode_barang.id' ,
'left');
// $this->db->where('items.account_id', $account_id);
$this->db->group_by('items.id');
// ✅ INI KUNCINYA
$this->db->having('stok >', 0);
$data = $this->db->get()->result();
echo json_encode($data);
}
public function get_lokasi_asset(){
$data = $this->db
->get('lokasi_asset')
->result();
echo json_encode($data);
}
private function update_item_stok($item_id)
{
$stok = $this->db->query("
SELECT COALESCE(SUM(qty * IF(tipe='masuk',1,-1)),0) as stok
FROM stock_logs
WHERE item_id = ?
", [$item_id])->row()->stok;
$this->db->where('id', $item_id)
->update('items', [
'stok' => $stok
]);
}
}
}
+23 -6
View File
@@ -15,12 +15,29 @@ class AttendanceWebhook extends CI_Controller {
// =========================
// AMBIL SN MESIN
// =========================
$sn = $this->input->get('SN');
$sn = trim((string) $this->input->get('SN', true));
if ($sn === '' || !preg_match('/^[A-Za-z0-9._-]{1,100}$/', $sn)) {
show_error('Serial number perangkat tidak valid.', 400);
}
$allowedSerials = array_filter(array_map('trim', explode(',', (string) app_env('ATTENDANCE_ALLOWED_SERIALS', ''))));
if (!empty($allowedSerials) && !in_array($sn, $allowedSerials, true)) {
log_message('error', 'Attendance webhook ditolak untuk SN: ' . $sn);
show_error('Perangkat tidak terdaftar.', 403);
}
if (ENVIRONMENT === 'production' && empty($allowedSerials)) {
log_message('error', 'ATTENDANCE_ALLOWED_SERIALS belum dikonfigurasi di production.');
show_error('Konfigurasi perangkat belum tersedia.', 503);
}
// =========================
// RAW BODY DARI MESIN
// =========================
$body = file_get_contents('php://input');
$body = file_get_contents('php://input', false, null, 0, 1048576);
if ($body === false) {
show_error('Payload tidak dapat dibaca.', 400);
}
// =========================
// LOG DIRECTORY
@@ -28,7 +45,7 @@ class AttendanceWebhook extends CI_Controller {
$logDir = APPPATH . 'logs/fingerprint/';
if (!is_dir($logDir)) {
mkdir($logDir, 0777, true);
mkdir($logDir, 0750, true);
}
// =========================
@@ -58,7 +75,7 @@ class AttendanceWebhook extends CI_Controller {
continue;
}
$logText .= $line . "\n";
$logText .= str_replace(array("\r", "\0"), '', $line) . "\n";
// contoh parsing
$data = explode("\t", $line);
@@ -75,11 +92,11 @@ class AttendanceWebhook extends CI_Controller {
// =========================
// SIMPAN LOG
// =========================
file_put_contents($logFile, $logText, FILE_APPEND);
file_put_contents($logFile, $logText, FILE_APPEND | LOCK_EX);
// =========================
// RESPONSE KE MESIN
// =========================
echo "OK";
}
}
}
@@ -1,65 +1,112 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH.'exceptions/BusinessException.php';
class Attendancemonitoring extends CI_Controller {
class Attendancemonitoring extends MY_Controller
{
public function __construct()
{
parent::__construct();
$this->load->database();
$this->load->library('ApprovalService');
}
private function companyId() { return (int)$this->session->userdata('company_id'); }
private function userId() { return (int)$this->session->userdata('user_id'); }
private function roleId() { return (int)$this->session->userdata('role_id'); }
private function can($action) { return is_master_admin_user() || check_permission('hr_attendance',$action) || check_permission('employees',$action); }
public function index()
{
$data = [
"active_menu" => "attendance_monitoring"
];
$this->load->view('partials/header', $data);
$this->load->view('employees/attendance_monitoring');
$this->load->view('partials/footer');
$employees=$this->db->select('id,employee_code,full_name')->where('is_active',1);
if($this->db->field_exists('company_id','k_employees'))$employees->where('company_id',$this->companyId());
$data=array('active_menu'=>'attendance_monitoring','employees'=>$employees->order_by('full_name')->get('k_employees')->result(),'permissions'=>array('create'=>$this->can('can_create'),'submit'=>$this->can('can_submit')||$this->can('can_create'),'approve'=>$this->can('can_approve'),'update'=>$this->can('can_update')));
$this->load->view('partials/header',$data);$this->load->view('employees/attendance_monitoring',$data);$this->load->view('partials/footer');
}
public function get_data()
{
$date = $this->input->get('date');
if(!$date){
$date = date('Y-m-d');
}
$data = $this->db->query("
SELECT
a.*,
e.employee_code,
e.full_name,
s.shift_name
FROM k_attendances a
LEFT JOIN k_employees e ON e.id = a.employee_id
LEFT JOIN k_shifts s ON s.id = a.shift_id
WHERE a.attendance_date = '$date'
ORDER BY a.checkin_time DESC
")->result();
$rows = [];
$no = 1;
foreach($data as $d){
$rows[] = [
$no++,
$d->employee_code,
$d->full_name,
$d->shift_name,
$d->checkin_time,
$d->checkout_time,
$d->attendance_status
];
}
echo json_encode([
'data' => $rows
]);
$date=$this->validDate($this->input->get('date'))?:date('Y-m-d');
$companySql=$this->db->field_exists('company_id','k_employees')?' AND e.company_id='.(int)$this->companyId():'';
$rows=$this->db->query("SELECT e.employee_code,e.full_name,sh.shift_name,a.id attendance_id,a.checkin_time,a.checkout_time,a.attendance_status
FROM k_employee_shift_schedules s JOIN k_employees e ON e.id=s.employee_id LEFT JOIN k_shifts sh ON sh.id=s.shift_id
LEFT JOIN k_attendances a ON a.employee_id=s.employee_id AND a.attendance_date=?
WHERE DATE(s.start_datetime)=? AND s.status IN('published','done') AND e.is_active=1{$companySql}
ORDER BY sh.checkin_time,e.full_name",array($date,$date))->result();
$data=array();$no=1;$labels=array('present'=>'Hadir','late'=>'Terlambat','alpha'=>'Alpa','annual'=>'Cuti','sick'=>'Sakit','permit'=>'Izin','maternity'=>'Melahirkan','unpaid'=>'Cuti Tidak Dibayar','holiday'=>'Libur','not_recorded'=>'Belum Absen');
foreach($rows as$r){$status=$r->attendance_id?$r->attendance_status:'not_recorded';$class=$status==='present'?'success':($status==='late'?'warning':($status==='alpha'?'danger':'secondary'));$data[]=array($no++,html_escape($r->employee_code),html_escape($r->full_name),html_escape($r->shift_name?:'-'),$r->checkin_time?date('H:i',strtotime($r->checkin_time)):'-',$r->checkout_time?date('H:i',strtotime($r->checkout_time)):'-','<span class="badge bg-'.$class.'">'.html_escape($labels[$status]??ucfirst($status)).'</span>');}
return $this->json(true,'Jadwal kehadiran ditemukan.',array('data'=>$data));
}
}
public function manual_requests()
{
$q=$this->db->select('r.*,e.employee_code,e.full_name,u.nama creator_name')->from('manual_attendance_requests r')->join('k_employees e','e.id=r.employee_id')->join('users u','u.id=r.created_by','left')->where('r.company_id',$this->companyId());
if(trim((string)$this->input->get('status'))!=='')$q->where('r.status',trim((string)$this->input->get('status')));
$data=array();foreach($q->order_by('r.id','DESC')->get()->result()as$r){$actions='<button class="btn btn-sm btn-outline-primary manual-detail" data-id="'.$r->id.'"><i class="bi bi-eye"></i></button> ';
if(in_array($r->status,array('draft','rejected'),true)&&$this->can('can_create')&&((int)$r->created_by===$this->userId()||$this->can('can_update')))$actions.='<button class="btn btn-sm btn-warning manual-submit" data-id="'.$r->id.'">Ajukan</button> ';
if($r->status==='submitted'&&$this->can('can_approve'))$actions.='<button class="btn btn-sm btn-success manual-approve" data-id="'.$r->id.'">Approve</button> <button class="btn btn-sm btn-outline-danger manual-reject" data-id="'.$r->id.'">Reject</button>';
$data[]=array('request'=>$r->request_no,'employee'=>$r->employee_code.' - '.$r->full_name,'datetime'=>date('d/m/Y',strtotime($r->attendance_date)).' '.substr($r->attendance_time,0,5),'action'=>$r->attendance_action==='check_in'?'Masuk':'Pulang','reason'=>$r->reason,'status'=>$r->status,'creator'=>$r->creator_name?:'-','actions'=>$actions);}
return$this->json(true,'Daftar pengajuan ditemukan.',array('data'=>$data));
}
public function manual_detail($id)
{
$row=$this->request((int)$id,false);return$row?$this->json(true,'Detail pengajuan.',array('request'=>$row)):$this->json(false,'Pengajuan tidak ditemukan.',array(),404);
}
public function save_manual()
{
try{$employeeId=(int)$this->input->post('employee_id');$date=$this->validDate($this->input->post('attendance_date'));$time=$this->validTime($this->input->post('attendance_time'));$action=$this->input->post('attendance_action');$reason=trim((string)$this->input->post('reason',true));
if(!$employeeId||!$date||!$time||!in_array($action,array('check_in','check_out'),true)||$reason==='')throw new BusinessException('Karyawan, tanggal, jam, jenis absen, dan alasan wajib diisi.');
$employee=$this->db->where('id',$employeeId)->where('is_active',1);if($this->db->field_exists('company_id','k_employees'))$employee->where('company_id',$this->companyId());if(!$employee->get('k_employees')->row())throw new BusinessException('Karyawan aktif tidak ditemukan pada perusahaan ini.');
if($this->isLocked($date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');
$duplicate=$this->db->where(array('company_id'=>$this->companyId(),'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_action'=>$action))->where_in('status',array('draft','submitted','approved'))->count_all_results('manual_attendance_requests');if($duplicate)throw new BusinessException('Pengajuan aktif untuk karyawan, tanggal, dan jenis absen tersebut sudah ada.');
$last=(int)$this->db->select_max('id','last_id')->get('manual_attendance_requests')->row()->last_id;$no='MAN-'.date('Ymd',strtotime($date)).'-'.str_pad((string)($last+1),5,'0',STR_PAD_LEFT);
$this->db->insert('manual_attendance_requests',array('company_id'=>$this->companyId(),'request_no'=>$no,'employee_id'=>$employeeId,'attendance_date'=>$date,'attendance_time'=>$time,'attendance_action'=>$action,'reason'=>$reason,'status'=>'draft','created_by'=>$this->userId(),'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();log_activity('manual_attendance','create','Membuat draft absensi manual '.$no,'success');return$this->json(true,'Draft absensi manual berhasil dibuat.',array('id'=>$id));
}catch(Throwable$e){return$this->json(false,$e->getMessage(),array(),422);}
}
public function submit_manual($id)
{
return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r)throw new BusinessException('Pengajuan tidak ditemukan.');if(!in_array($r->status,array('draft','rejected'),true))throw new BusinessException('Hanya draft atau pengajuan ditolak yang dapat diajukan.');if((int)$r->created_by!==$this->userId()&&!$this->can('can_update'))throw new BusinessException('Anda tidak berhak mengajukan dokumen ini.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->create('hr','manual_attendance',$r->id,0,$this->userId(),array('request_no'=>$r->request_no,'employee_id'=>$r->employee_id,'attendance_date'=>$r->attendance_date,'attendance_time'=>$r->attendance_time,'attendance_action'=>$r->attendance_action,'reason'=>$r->reason));$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'submitted','approval_request_id'=>$approval->id,'submitted_by'=>$this->userId(),'submitted_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>null,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan dikirim untuk persetujuan.');});
}
public function approve_manual($id)
{
return$this->transaction(function()use($id){$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');if($this->isLocked($r->attendance_date))throw new BusinessException('Periode absensi sudah dikunci untuk payroll.');$approval=$this->approvalservice->act($r->approval_request_id,'approved',$this->userId(),$this->roleId(),trim((string)$this->input->post('notes',true))?:null);if($approval->status==='approved'){$attendanceId=$this->applyAttendance($r);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'approved','approved_by'=>$this->userId(),'approved_at'=>date('Y-m-d H:i:s'),'applied_attendance_id'=>$attendanceId,'updated_at'=>date('Y-m-d H:i:s')));}return$this->json(true,'Absensi manual disetujui dan masuk ke daftar kehadiran.');});
}
public function reject_manual($id)
{
return$this->transaction(function()use($id){$reason=trim((string)$this->input->post('reason',true));if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');$r=$this->request((int)$id,true);if(!$r||$r->status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');$this->approvalservice->act($r->approval_request_id,'rejected',$this->userId(),$this->roleId(),$reason);$this->db->where('id',$r->id)->update('manual_attendance_requests',array('status'=>'rejected','rejected_by'=>$this->userId(),'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_at'=>date('Y-m-d H:i:s')));return$this->json(true,'Pengajuan absensi manual ditolak.');});
}
private function applyAttendance($request)
{
$schedule=$this->db->select('s.*,sh.late_tolerance_minutes')->from('k_employee_shift_schedules s')->join('k_shifts sh','sh.id=s.shift_id','left')->where('s.employee_id',$request->employee_id)->where('DATE(s.start_datetime)',$request->attendance_date)->where_in('s.status',array('published','done'))->order_by('s.id')->get()->row();if(!$schedule)throw new BusinessException('Jadwal kerja karyawan pada tanggal tersebut tidak ditemukan atau belum dipublikasikan.');
$attendance=$this->db->query('SELECT * FROM k_attendances WHERE employee_id=? AND attendance_date=? FOR UPDATE',array($request->employee_id,$request->attendance_date))->row();$dateTime=$request->attendance_date.' '.$request->attendance_time;$note='[MANUAL '.$request->request_no.'] '.$request->reason;
if($request->attendance_action==='check_in'){
if($attendance&&!empty($attendance->checkin_time)&&$attendance->attendance_status!=='alpha')throw new BusinessException('Jam masuk sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.');
$late=max(0,(int)floor((strtotime($dateTime)-(strtotime($schedule->start_datetime)+((int)$schedule->late_tolerance_minutes*60)))/60));$data=array('checkin_time'=>$dateTime,'late_minutes'=>$late,'attendance_status'=>$late>0?'late':'present','shift_id'=>$schedule->shift_id,'notes'=>trim(($attendance&&$attendance->notes?$attendance->notes."\n":'').$note));
if($attendance){$this->db->where('id',$attendance->id)->update('k_attendances',$data);return(int)$attendance->id;}$data['employee_id']=$request->employee_id;$data['attendance_date']=$request->attendance_date;$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('k_attendances',$data);return(int)$this->db->insert_id();
}
if(!$attendance||empty($attendance->checkin_time))throw new BusinessException('Jam masuk belum tercatat. Ajukan jam masuk terlebih dahulu.');if(!empty($attendance->checkout_time))throw new BusinessException('Jam pulang sudah tercatat. Gunakan alur koreksi jika waktunya perlu diubah.');if(strtotime($dateTime)<=strtotime($attendance->checkin_time))throw new BusinessException('Jam pulang harus setelah jam masuk.');
$work=round((strtotime($dateTime)-strtotime($attendance->checkin_time))/3600,2);$early=max(0,(int)floor((strtotime($schedule->end_datetime)-strtotime($dateTime))/60));$overtime=max(0,round((strtotime($dateTime)-strtotime($schedule->end_datetime))/3600,2));$this->db->where('id',$attendance->id)->update('k_attendances',array('checkout_time'=>$dateTime,'work_hours'=>$work,'early_leave_minutes'=>$early,'overtime_hours'=>$overtime,'notes'=>trim(($attendance->notes?$attendance->notes."\n":'').$note)));return(int)$attendance->id;
}
private function request($id,$lock=false){return$this->db->query('SELECT r.*,e.employee_code,e.full_name FROM manual_attendance_requests r JOIN k_employees e ON e.id=r.employee_id WHERE r.id=? AND r.company_id=?'.($lock?' FOR UPDATE':''),array($id,$this->companyId()))->row();}
private function isLocked($date)
{
if ($this->db->table_exists('payroll_attendance_locks')) {
$range = $this->db->where(array('company_id'=>$this->companyId(),'status'=>'locked'))
->where('start_date <=',$date)->where('end_date >=',$date)
->get('payroll_attendance_locks')->row();
if ($range) return true;
}
return (bool)$this->db->where(array('period'=>substr($date,0,7),'status'=>'locked'))->get('attendance_period_locks')->row();
}
private function validDate($value){$d=DateTime::createFromFormat('Y-m-d',(string)$value);return$d&&$d->format('Y-m-d')===$value?$value:null;}
private function validTime($value){foreach(array('H:i','H:i:s')as$f){$d=DateTime::createFromFormat($f,(string)$value);if($d&&$d->format($f)===$value)return$d->format('H:i:s');}return null;}
private function transaction($callback){$this->db->trans_begin();try{$response=$callback();if($this->db->trans_status()===false)throw new RuntimeException('Transaksi database gagal.');$this->db->trans_commit();return$response;}catch(Throwable$e){$this->db->trans_rollback();return$this->json(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);}}
private function json($status,$message,$data=array(),$code=200){return$this->output->set_status_header($code)->set_content_type('application/json')->set_output(json_encode(array_merge(array('status'=>$status,'message'=>$message),$data)));}
}
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Attendancemonthly extends CI_Controller {
class Attendancemonthly extends MY_Controller {
public function __construct()
{
@@ -100,4 +100,4 @@ class Attendancemonthly extends CI_Controller {
]);
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Attendancemonthly extends CI_Controller {
class Attendancemonthly extends MY_Controller {
public function __construct()
{
@@ -62,4 +62,4 @@ class Attendancemonthly extends CI_Controller {
'data' => $rows
]);
}
}
}
+88 -30
View File
@@ -3,42 +3,87 @@ defined('BASEPATH') OR exit('No direct script access allowed');
class Auth extends CI_Controller {
public function __construct()
{
parent::__construct();
public function __construct()
{
parent::__construct();
$this->load->library('UserSessionService');
}
public function index()
{
if ($this->session->userdata('logged_in')) {
redirect('dashboard');
}
$this->load->view('auth/login');
public function index()
{
if ($this->session->userdata('logged_in')) {
redirect('dashboard');
}
$notices=array('expired'=>'Masa login Anda telah berakhir. Silakan login kembali.','revoked'=>'Session perangkat telah diakhiri. Silakan login kembali.','device_limit'=>'Login ditolak karena batas perangkat aktif telah tercapai.');
$key=(string)$this->input->get('notice',true);
$this->load->view('auth/login',array('auth_notice'=>$notices[$key]??''));
}
public function process_login()
{
$username = $this->input->post('username');
$password = $this->input->post('password');
public function process_login()
{
if (strtoupper($this->input->method()) !== 'POST') {
show_error('Metode request tidak diizinkan.', 405);
}
$lockedUntil = (int) $this->session->userdata('login_locked_until');
if ($lockedUntil > time()) {
$remaining = max(1, (int) ceil(($lockedUntil - time()) / 60));
$this->session->set_flashdata('error', 'Terlalu banyak percobaan login. Coba kembali dalam ' . $remaining . ' menit.');
redirect('auth');
return;
}
$username = trim((string) $this->input->post('username', true));
$password = (string) $this->input->post('password');
if ($username === '' || $password === '') {
$this->session->set_flashdata('error', 'Username dan password wajib diisi.');
redirect('auth');
return;
}
$user = $this->db->get_where('v_users', ['username' => $username])->row();
if ($user && password_verify($password, $user->password)) {
$user = $this->db->get_where('v_users', ['username' => $username])->row();
$account = $user ? $this->db->get_where('users', array('id' => (int)$user->id))->row() : null;
if ($user && (int)$user->role_is_active === 1 && password_verify($password, $user->password)) {
$this->session->sess_regenerate(true);
try {
$loginSecurity = $this->usersessionservice->begin($account ?: $user);
} catch (Throwable $exception) {
log_message('error', 'Pencatatan session login gagal: ' . $exception->getMessage());
$this->session->set_flashdata('error', 'Login belum dapat diproses karena pencatatan keamanan session gagal.');
redirect('auth');
return;
}
if (!$loginSecurity['allowed']) {
$this->session->set_flashdata('error', $loginSecurity['message']);
redirect('auth');
return;
}
$this->session->unset_userdata(array('login_attempts', 'login_locked_until'));
// Decode permissions
$permissions = json_decode($user->permissions, true);
if (!is_array($permissions)) {
$permissions = [];
}
$permissions = json_decode($user->permissions, true);
if (!is_array($permissions)) {
$permissions = [];
}
$role = $this->db->select('is_super_admin')->get_where('roles', array('id' => (int)$user->role_id))->row();
$session_data = [
'user_id' => $user->id,
'nama' => $user->nama,
'username' => $user->username,
'role' => $user->nama_role,
'role' => $user->nama_role,
'role_id' => (int) $user->role_id,
'is_super_admin'=> $role && (int)$role->is_super_admin === 1,
'permissions' => $permissions,
'logged_in' => TRUE
'logged_in' => TRUE,
'email' => $account->email ?? null,
'profile_photo' => $account->profile_photo ?? null,
'auth_login_key'=> $loginSecurity['login_key'],
'auth_started_at'=> $loginSecurity['login_at'],
'auth_expires_at'=> $loginSecurity['expires_at']
];
$this->session->set_userdata($session_data);
@@ -53,7 +98,19 @@ class Auth extends CI_Controller {
redirect('dashboard');
} else {
} else {
$this->usersessionservice->recordInvalid($account, $username);
$attempts = (int) $this->session->userdata('login_attempts') + 1;
$this->session->set_userdata('login_attempts', $attempts);
if ($attempts >= 5) {
$this->session->set_userdata(array(
'login_attempts' => 0,
'login_locked_until' => time() + 300
));
}
// 🔥 LOG LOGIN GAGAL
log_activity(
@@ -63,14 +120,14 @@ class Auth extends CI_Controller {
'failed'
);
$this->session->set_flashdata('error', 'Username atau Password salah!');
$this->session->set_flashdata('error', 'Username atau password salah.');
redirect('auth');
}
}
public function logout()
{
$username = $this->session->userdata('username');
public function logout()
{
$username = $this->session->userdata('username');
// 🔥 LOG LOGOUT (SEBELUM DESTROY SESSION)
log_activity(
@@ -80,8 +137,9 @@ class Auth extends CI_Controller {
'success'
);
$this->session->sess_destroy();
$this->usersessionservice->endCurrent('logged_out', 'Logout oleh pengguna.');
$this->session->sess_destroy();
redirect('auth');
}
}
?>
?>
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Basejurnal extends CI_Controller {
class Basejurnal extends MY_Admin_Controller {
public function __construct()
{
@@ -287,4 +287,4 @@ public function update()
echo json_encode($data);
}
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Budgetcheck extends CI_Controller
{
private function boot()
{
if (!$this->input->is_cli_request()) show_404();
$this->load->database();
$access = $this->db->order_by('is_default', 'DESC')->get('user_companies')->row();
if (!$access) throw new RuntimeException('Tidak ada user-company untuk smoke test.');
$this->session->set_userdata(array(
'user_id' => $access->user_id,
'company_id' => $access->company_id,
'role' => 'Admin',
'logged_in' => true,
));
$this->load->library(array('CompanyContext', 'BudgetService'));
return $access;
}
public function index()
{
$this->boot();
$required = array(
'budgets' => array('company_id', 'fiscal_year', 'status', 'control_mode'),
'budget_lines' => array('company_id', 'budget_id', 'period', 'account_id', 'amount'),
'purchase_order_lines' => array('budget_account_id'),
);
$missing = array();
foreach ($required as $table => $columns) {
foreach ($columns as $column) {
if (!$this->db->field_exists($column, $table)) $missing[] = $table.'.'.$column;
}
}
$duplicateActive = (int) $this->db->query(
"SELECT COUNT(*) total FROM (SELECT company_id, fiscal_year FROM budgets WHERE status='active' GROUP BY company_id, fiscal_year HAVING COUNT(*) > 1) x"
)->row()->total;
$duplicateLines = (int) $this->db->query(
"SELECT COUNT(*) total FROM (SELECT budget_id, period, account_id FROM budget_lines GROUP BY budget_id, period, account_id HAVING COUNT(*) > 1) x"
)->row()->total;
$rows = $this->budgetservice->report(array(
'from' => date('Y-01-01'),
'to' => date('Y-12-31'),
'account_id' => 0,
));
$ok = !$missing && $duplicateActive === 0 && $duplicateLines === 0;
echo json_encode(array(
'migration' => (int) $this->db->get('migrations')->row()->version,
'missing_schema' => $missing,
'money_20_000_50' => $this->budgetservice->money('20.000,50'),
'active_budget_duplicates' => $duplicateActive,
'budget_line_duplicates' => $duplicateLines,
'report_rows' => count($rows),
'status' => $ok ? 'OK' : 'REVIEW',
), JSON_PRETTY_PRINT).PHP_EOL;
}
public function workflow()
{
$access = $this->boot();
$user = (int) $access->user_id;
$budgetIds = array();
$doubleSubmitBlocked = false;
try {
$account = $this->db
->where(array(
'company_id' => $access->company_id,
'tipe' => 'expense',
'is_active' => 1,
'allow_posting' => 1,
))
->order_by('id')->get('accounts')->row();
if (!$account) throw new RuntimeException('Akun pengeluaran posting tidak tersedia.');
$id = $this->budgetservice->create(array(
'name' => 'QA Budget Temporary',
'fiscal_year' => 2199,
'control_mode' => 'warning',
), $user);
$budgetIds[] = $id;
$this->budgetservice->saveAnnualLines($id, array(array(
'account_id' => $account->id,
'month' => array(1 => '20.000,50', 2 => '10.000,25'),
)), $user);
$this->budgetservice->workflow($id, 'submit', $user);
try {
$this->budgetservice->workflow($id, 'submit', $user);
} catch (Throwable $expected) {
$doubleSubmitBlocked = true;
}
$status = $this->budgetservice->workflow($id, 'approve', $user);
$revision = $this->budgetservice->revise($id, 'Smoke test revisi', $user);
$budgetIds[] = $revision;
$revisionDraft = $this->budgetservice->find($revision)->status === 'draft';
$ok = $status === 'active' && $doubleSubmitBlocked && $revisionDraft;
echo json_encode(array(
'draft_submit_approve' => $status === 'active',
'double_submit_blocked' => $doubleSubmitBlocked,
'revision_draft' => $revisionDraft,
'account_only_budget' => true,
'status' => $ok ? 'OK' : 'REVIEW',
), JSON_PRETTY_PRINT).PHP_EOL;
} finally {
if ($budgetIds) {
$this->db->where_in('budget_id', $budgetIds)->delete('budget_lines');
$this->db->group_start()->where_in('budget_id', $budgetIds)->or_where_in('entity_id', $budgetIds)->group_end()
->where('entity_type', 'budget')->delete('budget_audit_logs');
$this->db->where_in('budget_id', $budgetIds)->delete('budget_revisions');
$this->db->where_in('id', $budgetIds)->delete('budgets');
}
}
}
}
+55
View File
@@ -0,0 +1,55 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Budgets extends MY_Controller
{
private$userId;
public function __construct(){parent::__construct();$this->load->database();$this->load->library(array('CompanyContext','BudgetService'));$this->userId=(int)$this->session->userdata('user_id');}
public function index()
{
$company=$this->companycontext->id();$tab=$this->input->get('tab')==='accounts'?'accounts':'list';
$filters=array('from'=>$this->input->get('from')?:date('Y-m'),'to'=>$this->input->get('to')?:date('Y-m'),'account_id'=>(int)$this->input->get('account_id'));
$listAccount=(int)$this->input->get('list_account_id');
$accounts=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('kode_akun')->get('accounts')->result();
$budgetAccounts=$this->db->select('a.id,a.kode_akun,a.nama_akun,a.tipe')->distinct()->from('accounts a')->join('account_budget_entries e','e.account_id=a.id')->where(array('e.company_id'=>$company,'e.is_active'=>1))->order_by('a.kode_akun')->get()->result();
$entries=$this->budgetservice->entries($listAccount);$reportRows=$tab==='accounts'?$this->budgetservice->report($filters):array();
$listTotal=array_sum(array_map(function($entry){return(float)$entry->amount;},$entries));
$accountTotal=array_sum(array_map(function($row){return(float)$row['budget'];},$reportRows));
$data=array('active_menu'=>'budgets','active_tab'=>$tab,'accounts'=>$accounts,'budget_accounts'=>$budgetAccounts,'budget_account_count'=>count($budgetAccounts),'entries'=>$entries,'list_total_budget'=>$listTotal,'reminders'=>$this->budgetservice->currentReminders(),'list_account_id'=>$listAccount,'filters'=>$filters,'report_rows'=>$reportRows,'account_total_budget'=>$accountTotal,'can_create'=>$this->allowed('can_create'),'can_update'=>$this->allowed('can_update'),'can_delete'=>$this->allowed('can_delete'),'can_export'=>$this->allowed('can_export'));
$this->load->view('partials/header',$data);$this->load->view('budgets/index',$data);$this->load->view('partials/footer');
}
public function save_entry()
{
$id=(int)$this->input->post('id');return$this->handle(fn()=>$this->budgetservice->saveEntry($this->input->post(),$this->userId),$id?'Budget berhasil diperbarui.':'Budget berhasil ditambahkan.',$id?'can_update':'can_create');
}
public function delete_entry($id){return$this->handle(fn()=>$this->budgetservice->deleteEntry($id,$this->userId),'Budget berhasil dinonaktifkan.','can_delete');}
public function complete_reminder($id){return$this->handle(fn()=>$this->budgetservice->completeReminder($id,$this->userId),'Pengingat pembayaran bulan ini ditandai selesai.','can_update');}
public function export_list($format='excel')
{
$this->requirePermission('budgets','can_export');$accountId=(int)$this->input->get('list_account_id');$rows=array();
$states=array('completed'=>'Selesai bulan ini','overdue'=>'Terlambat','due'=>'Jatuh tempo hari ini','upcoming'=>'Segera jatuh tempo','scheduled'=>'Terjadwal','future'=>'Belum berlaku','inactive_period'=>'Bukan bulan berjalan','none'=>'Tidak ada');
foreach($this->budgetservice->entries($accountId)as$entry)$rows[]=array('title'=>$entry->title,'account'=>$entry->kode_akun.' - '.$entry->nama_akun,'type'=>$entry->entry_type==='recurring'?'Berulang setiap bulan':'Khusus satu bulan','period'=>$entry->period,'amount'=>(float)$entry->amount,'reminder'=>(int)$entry->reminder_enabled?(($entry->entry_type==='recurring'?'Setiap tanggal ':'Tanggal ').$entry->reminder_day):'Tidak ada','reminder_status'=>$states[$entry->reminder_state]??'-','notes'=>$entry->notes?:'-');
return$this->deliverExport($format,'Daftar Budget',$accountId?'Filter satu akun':'Semua akun',array('title'=>'Nama Budget','account'=>'Akun','type'=>'Pola','period'=>'Mulai / Bulan','amount'=>'Nominal Budget','reminder'=>'Pengingat Pembayaran','reminder_status'=>'Status Pengingat','notes'=>'Keterangan'),$rows,array('amount'),'Daftar-Budget');
}
public function export_accounts($format='excel')
{
$this->requirePermission('budgets','can_export');$filters=$this->filters();$rows=array();$types=array('asset'=>'Aset','liability'=>'Liabilitas','equity'=>'Ekuitas','revenue'=>'Pendapatan','expense'=>'Beban');
foreach($this->budgetservice->report($filters)as$row)$rows[]=array('account'=>$row['kode_akun'].' - '.$row['nama_akun'],'type'=>$types[$row['tipe']]??ucfirst($row['tipe']),'budget'=>$row['budget'],'actual'=>$row['actual'],'commitment'=>$row['commitment'],'usage'=>$row['usage'],'remaining'=>$row['remaining'],'percentage'=>$row['percentage'],'condition'=>$row['indicator']==='over'?'Melebihi':($row['indicator']==='warning'?'Mendekati Batas':'Aman'));
return$this->deliverExport($format,'Budget Per Akun','Periode '.$filters['from'].' s.d. '.$filters['to'],array('account'=>'Akun','type'=>'Jenis Akun','budget'=>'Akumulasi Budget','actual'=>'Realisasi','commitment'=>'Komitmen PO','usage'=>'Total Pemakaian','remaining'=>'Sisa Budget','percentage'=>'Persentase (%)','condition'=>'Kondisi'),$rows,array('budget','actual','commitment','usage','remaining'),'Budget-Per-Akun-'.$filters['from'].'-'.$filters['to']);
}
public function create(){return$this->handle(fn()=>$this->budgetservice->create($this->input->post(),$this->userId),'Budget Draft berhasil dibuat.','can_create',true);}
public function save_lines($id){return$this->handle(fn()=>$this->budgetservice->saveAnnualLines($id,(array)$this->input->post('rows'),$this->userId),'Budget bulanan berhasil disimpan.','can_update');}
public function workflow($id,$action){return$this->handle(fn()=>$this->budgetservice->workflow($id,$action,$this->userId,$this->input->post('reason',true)),'Status budget berhasil diperbarui.',$action==='submit'?'can_create':'can_approve');}
public function revise($id){return$this->handle(fn()=>$this->budgetservice->revise($id,$this->input->post('reason',true),$this->userId),'Draft revisi berhasil dibuat.','can_update',true);}
public function detail($id){$budget=$this->budgetservice->find($id);if(!$budget)return json_response(false,'Budget tidak ditemukan.',array(),404);return json_response(true,'Detail budget ditemukan.',array('result'=>array('budget'=>$budget,'lines'=>$this->budgetservice->lines($id))));}
public function export(){$this->requirePermission('budgets','can_export');$f=$this->filters();$rows=$this->budgetservice->report($f);$this->output->set_content_type('text/csv')->set_header('Content-Disposition: attachment; filename="budget-'.$f['to'].'.csv"');$h=fopen('php://output','w');fputcsv($h,array('Kode Akun','Nama Akun','Budget','Realisasi','Komitmen','Pemakaian','Sisa','Persentase'));foreach($rows as$r)fputcsv($h,array($r['kode_akun'],$r['nama_akun'],$r['budget'],$r['actual'],$r['commitment'],$r['usage'],$r['remaining'],$r['percentage']));fclose($h);}
public function pdf($id){$this->requirePermission('budgets','can_export');$budget=$this->budgetservice->find($id);if(!$budget)show_404();$html=$this->load->view('budgets/pdf',array('budget'=>$budget,'lines'=>$this->budgetservice->lines($id),'company'=>$this->db->get_where('companies',array('id'=>$this->companycontext->id()))->row()),true);require_once FCPATH.'vendor/autoload.php';$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();$pdf->stream($budget->budget_no.'.pdf',array('Attachment'=>false));}
private function filters(){return array('from'=>$this->input->get_post('from')?:date('Y-m'),'to'=>$this->input->get_post('to')?:date('Y-m'),'account_id'=>(int)$this->input->get_post('account_id'));}
private function deliverExport($format,$title,$subtitle,array$columns,array$rows,array$moneyColumns,$filename)
{
$format=strtolower((string)$format);if(!in_array($format,array('pdf','excel'),true))show_404();$company=$this->db->get_where('companies',array('id'=>$this->companycontext->id()))->row();
if($format==='pdf'){$html=$this->load->view('budgets/export_pdf',array('title'=>$title,'subtitle'=>$subtitle,'columns'=>$columns,'rows'=>$rows,'money_columns'=>$moneyColumns,'company'=>$company),true);require_once FCPATH.'vendor/autoload.php';$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();$pdf->stream($filename.'.pdf',array('Attachment'=>false));return;}
require_once FCPATH.'vendor/autoload.php';$book=new\PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$book->getActiveSheet();$sheet->setTitle(substr(preg_replace('/[^A-Za-z0-9 ]/','',$title),0,31));$last=\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($columns));$companyName=$company?($company->name??$company->company_name??'Perusahaan'):'Perusahaan';$sheet->setCellValue('A1',$companyName)->mergeCells('A1:'.$last.'1');$sheet->setCellValue('A2',$title)->mergeCells('A2:'.$last.'2');$sheet->setCellValue('A3',$subtitle)->mergeCells('A3:'.$last.'3');$column=1;foreach($columns as$label)$sheet->setCellValueByColumnAndRow($column++,5,$label);$rowNumber=6;foreach($rows as$row){$column=1;foreach($columns as$key=>$label){$sheet->setCellValueByColumnAndRow($column,$rowNumber,$row[$key]??null);if(in_array($key,$moneyColumns,true))$sheet->getStyleByColumnAndRow($column,$rowNumber)->getNumberFormat()->setFormatCode('#,##0.00');$column++;}$rowNumber++;}$sheet->getStyle('A1:'.$last.'2')->getFont()->setBold(true);$sheet->getStyle('A5:'.$last.'5')->getFont()->setBold(true);$sheet->freezePane('A6');if($rows)$sheet->setAutoFilter('A5:'.$last.($rowNumber-1));for($i=1;$i<=count($columns);$i++)$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i))->setAutoSize(true);while(ob_get_level()>0)ob_end_clean();header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="'.$filename.'-'.date('Ymd-His').'.xlsx"');header('Cache-Control: max-age=0');\PhpOffice\PhpSpreadsheet\IOFactory::createWriter($book,'Xlsx')->save('php://output');$book->disconnectWorksheets();exit;
}
private function handle(callable$fn,$message,$permission,$open=false){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode request tidak diizinkan.',array(),405);$this->requirePermission('budgets',$permission);try{$result=$fn();if($this->input->is_ajax_request())return json_response(true,$message,array('result'=>$result));$this->session->set_flashdata('success',$message);redirect('budgets'.($open&&is_numeric($result)?'?budget_id='.$result:''));}catch(Throwable$e){if($this->input->is_ajax_request())return business_exception_response($e);$this->session->set_flashdata('error',$e->getMessage());redirect('budgets');}}
private function allowed($action){return is_master_admin_user()||check_permission('budgets',$action);}
}
+45 -17
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Bukubesar extends CI_Controller {
class Bukubesar extends MY_Admin_Controller {
public function __construct()
{
@@ -32,6 +32,7 @@ class Bukubesar extends CI_Controller {
public function get_accounts()
{
$data = $this->dm->setTable('accounts')
->where(array('company_id'=>(int)$this->companycontext->id(),'is_active'=>1,'allow_posting'=>1))
->order_by('kode_akun','ASC')
->get();
@@ -43,9 +44,26 @@ class Bukubesar extends CI_Controller {
// =========================
public function get_data()
{
$account_id = $this->input->get('account_id');
$start = $this->input->get('start_date');
$end = $this->input->get('end_date');
$account_id = (int) $this->input->get('account_id');
$start = (string) ($this->input->get('start_date') ?: date('Y-m-01'));
$end = (string) ($this->input->get('end_date') ?: date('Y-m-d'));
$companyId = (int) $this->companycontext->id();
if (!$account_id || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $start) || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $end) || $end < $start) {
return $this->output->set_content_type('application/json')->set_status_header(422)->set_output(json_encode(array('status'=>false,'message'=>'Akun dan periode Buku Besar tidak valid.')));
}
$account = $this->db->get_where('accounts', array('id'=>$account_id,'company_id'=>$companyId))->row();
if (!$account) {
return $this->output->set_content_type('application/json')->set_status_header(404)->set_output(json_encode(array('status'=>false,'message'=>'Akun tidak ditemukan pada perusahaan aktif.')));
}
$openingRow = $this->db->select('COALESCE(SUM(journal_details.debit),0) debit,COALESCE(SUM(journal_details.kredit),0) kredit', false)
->from('journal_details')->join('journals','journals.id=journal_details.journal_id')
->where(array('journals.company_id'=>$companyId,'journal_details.account_id'=>$account_id))
->where_in('journals.status', array('posted','reversed'))->where('journals.tanggal <', $start)->get()->row();
$normalDebit = in_array($account->tipe, array('asset','expense'), true);
$opening = $normalDebit ? (float)$openingRow->debit-(float)$openingRow->kredit : (float)$openingRow->kredit-(float)$openingRow->debit;
$this->db->select('
journals.tanggal,
@@ -59,29 +77,27 @@ class Bukubesar extends CI_Controller {
$this->db->from('journal_details');
$this->db->join('journals','journals.id = journal_details.journal_id');
$this->db->join('accounts','accounts.id = journal_details.account_id');
if ($account_id) {
$this->db->where('journal_details.account_id', $account_id);
}
if ($start && $end) {
$this->db->where('journals.tanggal >=', $start);
$this->db->where('journals.tanggal <=', $end);
}
$this->db->where_in('journals.status', array('posted', 'reversed'));
$this->db->where('journals.company_id', $companyId);
$this->db->where('journal_details.account_id', $account_id);
$this->db->where('journals.tanggal >=', $start);
$this->db->where('journals.tanggal <=', $end);
// 🔥 FILTER DEBIT/KREDIT TIDAK BOLEH 0 SEMUA
$this->db->group_start();
$this->db->where('journal_details.debit >', 0);
$this->db->or_where('journal_details.kredit >', 0);
$this->db->group_end();
$this->db->order_by('journals.tanggal','ASC');
$this->db->order_by('journals.tanggal','ASC')->order_by('journals.id','ASC')->order_by('journal_details.id','ASC');
$rows = $this->db->get()->result();
// =========================
// HITUNG SALDO BERJALAN
// =========================
$saldo = 0;
$saldo = $opening;
$totalDebit = 0;
$totalKredit = 0;
$result = [];
foreach ($rows as $r) {
@@ -93,6 +109,9 @@ class Bukubesar extends CI_Controller {
$saldo += ($r->kredit - $r->debit);
}
$totalDebit += (float) $r->debit;
$totalKredit += (float) $r->kredit;
$result[] = [
'tanggal' => $r->tanggal,
'no_ref' => $r->no_ref,
@@ -103,6 +122,15 @@ class Bukubesar extends CI_Controller {
];
}
echo json_encode($result);
return $this->output->set_content_type('application/json')->set_output(json_encode(array(
'status'=>true,
'data'=>$result,
'summary'=>array(
'saldo_awal'=>round($opening,2),
'total_debit'=>round($totalDebit,2),
'total_kredit'=>round($totalKredit,2),
'saldo_akhir'=>round($saldo,2),
),
)));
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Cameraqrscan extends CI_Controller {
class Cameraqrscan extends MY_Controller {
public function __construct()
{
+15
View File
@@ -0,0 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Cashbank extends MY_Controller
{
public function __construct(){parent::__construct();$this->load->database();$this->load->library('CashBankService');}
private function uid(){return(int)$this->session->userdata('user_id');}private function go($ok,$m){$this->session->set_flashdata($ok?'success':'error',$m);redirect('cashbank');}
public function index(){$d=array('active_menu'=>'cash_bank');$d['cash_accounts']=$this->db->select('c.*,a.kode_akun,a.nama_akun')->from('cash_accounts c')->join('accounts a','a.id=c.gl_account_id')->where('c.is_active',1)->get()->result();$d['accounts']=$this->db->order_by('kode_akun')->get('accounts')->result();$d['transactions']=$this->db->select('t.*,c.name account_name')->from('cash_transactions t')->join('cash_accounts c','c.id=t.cash_account_id')->order_by('t.transaction_date','DESC')->order_by('t.id','DESC')->limit(100)->get()->result();$d['statements']=$this->db->select('l.*,c.name account_name')->from('bank_statement_lines l')->join('bank_statement_imports i','i.id=l.import_id')->join('cash_accounts c','c.id=i.cash_account_id')->order_by('l.transaction_date','DESC')->limit(100)->get()->result();$d['positions']=$this->db->query("SELECT c.id,c.name,c.account_type,c.opening_balance+COALESCE(SUM(IF(t.status='posted',IF(t.direction='in',t.amount,-t.amount),0)),0) balance FROM cash_accounts c LEFT JOIN cash_transactions t ON t.cash_account_id=c.id GROUP BY c.id")->result();$this->load->view('partials/header',$d);$this->load->view('cash_bank/index',$d);$this->load->view('partials/footer');}
public function transaction(){try{$r=$this->cashbankservice->post(array('transaction_date'=>$this->input->post('date'),'cash_account_id'=>(int)$this->input->post('cash_account_id'),'transaction_type'=>$this->input->post('type'),'direction'=>$this->input->post('direction'),'amount'=>(float)$this->input->post('amount'),'counter_account_id'=>(int)$this->input->post('counter_account_id'),'reference_no'=>$this->input->post('reference_no',true),'description'=>$this->input->post('description',true),'idempotency_key'=>$this->input->post('idempotency_key',true)),$this->uid());$this->go(true,'Transaksi '.$r['transaction_no'].' tersimpan dengan status '.$r['status'].'.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function approve($id){$this->requireAdmin();try{$this->cashbankservice->approve($id,$this->uid());$this->go(true,'Pembayaran disetujui dan diposting.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function transfer(){try{$this->cashbankservice->transfer((int)$this->input->post('from_id'),(int)$this->input->post('to_id'),$this->input->post('date'),(float)$this->input->post('amount'),(float)$this->input->post('charge'),(int)$this->input->post('charge_account_id'),$this->input->post('reference_no',true),$this->uid(),$this->input->post('idempotency_key',true));$this->go(true,'Transfer berhasil diposting.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function import(){try{if(empty($_FILES['statement']['tmp_name']))throw new BusinessException('File CSV wajib dipilih.');$hash=hash_file('sha256',$_FILES['statement']['tmp_name']);$cid=(int)$this->input->post('cash_account_id');if($this->db->get_where('bank_statement_imports',array('cash_account_id'=>$cid,'file_hash'=>$hash))->row())throw new BusinessException('File ini sudah pernah diimpor.');$this->db->insert('bank_statement_imports',array('cash_account_id'=>$cid,'file_name'=>$_FILES['statement']['name'],'file_hash'=>$hash,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s')));$iid=$this->db->insert_id();$h=fopen($_FILES['statement']['tmp_name'],'r');$n=0;while(($r=fgetcsv($h,0,','))!==false){if(!$n++&&strtotime($r[0])===false)continue;if(count($r)<4)continue;$this->db->insert('bank_statement_lines',array('import_id'=>$iid,'line_no'=>$n,'transaction_date'=>date('Y-m-d',strtotime($r[0])),'description'=>$r[1],'reference_no'=>isset($r[2])?$r[2]:null,'debit'=>(float)$r[3],'credit'=>isset($r[4])?(float)$r[4]:0,'balance'=>isset($r[5])?(float)$r[5]:null));}fclose($h);$this->autoMatch($iid);$this->go(true,($n-1).' baris diimpor.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
private function autoMatch($iid){$imp=$this->db->get_where('bank_statement_imports',array('id'=>$iid))->row();foreach($this->db->get_where('bank_statement_lines',array('import_id'=>$iid))->result()as$l){$amount=$l->credit>0?$l->credit:$l->debit;$dir=$l->credit>0?'in':'out';$tx=$this->db->query("SELECT * FROM cash_transactions WHERE cash_account_id=? AND direction=? AND status='posted' AND ABS(amount-?)<0.01 AND transaction_date BETWEEN DATE_SUB(?,INTERVAL 3 DAY) AND DATE_ADD(?,INTERVAL 3 DAY) AND id NOT IN(SELECT matched_cash_transaction_id FROM bank_statement_lines WHERE matched_cash_transaction_id IS NOT NULL) ORDER BY IF(reference_no=?,0,1),ABS(DATEDIFF(transaction_date,?)) LIMIT 1",array($imp->cash_account_id,$dir,$amount,$l->transaction_date,$l->transaction_date,$l->reference_no,$l->transaction_date))->row();if($tx)$this->db->where('id',$l->id)->update('bank_statement_lines',array('match_status'=>'suggested','matched_cash_transaction_id'=>$tx->id,'match_score'=>$tx->reference_no===$l->reference_no?100:85));}}
public function match($line){$l=$this->db->get_where('bank_statement_lines',array('id'=>$line))->row();if(!$l||!$l->matched_cash_transaction_id)return$this->go(false,'Pasangan belum tersedia.');$this->db->where('id',$line)->update('bank_statement_lines',array('match_status'=>'matched'));$this->go(true,'Mutasi berhasil dicocokkan.');}
public function reconcile(){$id=(int)$this->input->post('cash_account_id');$date=$this->input->post('period_end');$row=$this->db->query("SELECT c.opening_balance+COALESCE(SUM(IF(t.direction='in',t.amount,-t.amount)),0)t FROM cash_accounts c LEFT JOIN cash_transactions t ON t.cash_account_id=c.id AND t.status='posted' AND t.transaction_date<=? WHERE c.id=? GROUP BY c.id",array($date,$id))->row();$book=$row?(float)$row->t:0;$statement=(float)$this->input->post('statement_balance');$or=(float)$this->input->post('outstanding_receipts');$op=(float)$this->input->post('outstanding_payments');$diff=round(($statement+$or-$op)-$book,2);$this->db->insert('bank_reconciliations',array('reconciliation_no'=>'BRC-'.date('YmdHis'),'cash_account_id'=>$id,'period_end'=>$date,'book_balance'=>$book,'statement_balance'=>$statement,'outstanding_receipts'=>$or,'outstanding_payments'=>$op,'difference'=>$diff,'status'=>abs($diff)<.01?'balanced':'variance','notes'=>$this->input->post('notes',true),'created_by'=>$this->uid()));$this->go(true,'Rekonsiliasi disimpan. Selisih Rp '.number_format($diff,2,',','.'));}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Companies extends MY_Admin_Controller
{
public function __construct(){parent::__construct();$this->load->database();$this->load->library(array('CompanyContext','MultiCurrencyService','AuditService'));}
public function index()
{
$companyId=(int)$this->companycontext->id();
$d=array('active_menu'=>'companies');
$d['active_company']=$this->db->get_where('companies',array('id'=>$companyId))->row();
$d['companies']=$this->db->order_by('name')->get('companies')->result();
$d['currencies']=$this->db->order_by('code')->get('currencies')->result();
$d['active_currencies']=$this->db->where('is_active',1)->order_by('code')->get('currencies')->result();
$d['bank_accounts']=$this->db->table_exists('company_bank_accounts')?$this->db->select('b.*,a.kode_akun,a.nama_akun')->from('company_bank_accounts b')->join('accounts a','a.id=b.gl_account_id AND a.company_id=b.company_id','left')->where('b.company_id',$companyId)->order_by('b.is_primary','DESC')->order_by('b.id')->get()->result():array();
$d['bank_gl_accounts']=$this->db->where(array('company_id'=>$companyId,'tipe'=>'asset','is_active'=>1,'allow_posting'=>1))->order_by('kode_akun')->get('accounts')->result();
$d['rates']=$this->db->select('r.*,c.name company_name')->from('exchange_rates r')->join('companies c','c.id=r.company_id')->where('r.company_id',$companyId)->order_by('rate_date','DESC')->limit(100)->get()->result();
$this->load->view('partials/header',$d);$this->load->view('companies/index',$d);$this->load->view('partials/footer',$d);
}
public function switch_company($id){$this->companycontext->assertAccess($id);$this->session->set_userdata('company_id',(int)$id);redirect('companies');}
public function update()
{
$this->postOnly();$id=(int)$this->companycontext->id();$company=$this->db->get_where('companies',array('id'=>$id))->row();if(!$company)show_404();
try{
$name=trim((string)$this->input->post('name',true));$currency=strtoupper(trim((string)$this->input->post('base_currency',true)));if($name==='')throw new RuntimeException('Nama perusahaan wajib diisi.');if(!$this->db->get_where('currencies',array('code'=>$currency,'is_active'=>1))->row())throw new RuntimeException('Mata uang dasar tidak valid.');
$logo=$this->uploadLogo((string)($company->logo_path??''));
$this->db->where('id',$id)->update('companies',array('name'=>$name,'legal_name'=>trim((string)$this->input->post('legal_name',true)),'tax_number'=>trim((string)$this->input->post('tax_number',true)),'address'=>trim((string)$this->input->post('address',true)),'phone'=>trim((string)$this->input->post('phone',true)),'email'=>trim((string)$this->input->post('email',true)),'logo_path'=>$logo,'base_currency'=>$currency));
$this->session->set_flashdata('success','Company Setting berhasil diperbarui.');
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
redirect('companies');
}
public function save()
{
$this->postOnly();try{$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$currency=strtoupper(trim((string)$this->input->post('base_currency',true)));if($code===''||$name==='')throw new RuntimeException('Kode dan nama perusahaan wajib diisi.');if($this->db->get_where('companies',array('company_code'=>$code))->row())throw new RuntimeException('Kode perusahaan sudah digunakan.');$this->db->insert('companies',array('company_code'=>$code,'name'=>$name,'legal_name'=>trim((string)$this->input->post('legal_name',true)),'tax_number'=>trim((string)$this->input->post('tax_number',true)),'address'=>trim((string)$this->input->post('address',true)),'phone'=>trim((string)$this->input->post('phone',true)),'email'=>trim((string)$this->input->post('email',true)),'base_currency'=>$currency?:'IDR','is_active'=>1,'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();$this->db->insert('user_companies',array('user_id'=>$this->session->userdata('user_id'),'company_id'=>$id,'is_default'=>0,'can_consolidate'=>1));$this->session->set_flashdata('success','Perusahaan baru berhasil ditambahkan.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies');
}
public function save_bank()
{
$this->postOnly();$company=(int)$this->companycontext->id();$transactionStarted=false;
try{
$id=(int)$this->input->post('id');$type=(string)$this->input->post('account_type',true);$opened=(string)$this->input->post('opened_at',true);$maturity=(string)$this->input->post('maturity_date',true);$interest=max(0,round((float)$this->input->post('interest_rate'),4));$gl=(int)$this->input->post('gl_account_id');
if(!in_array($type,array('savings','deposit'),true))throw new RuntimeException('Jenis rekening tidak valid.');
if(!$this->validDate($opened))throw new RuntimeException('Tanggal pembukaan rekening wajib diisi dengan benar.');
if($type==='deposit'&&!$this->validDate($maturity))throw new RuntimeException('Tanggal jatuh tempo wajib untuk rekening Deposito.');
if($type==='savings')$maturity=null;
if($maturity!==null&&$maturity<$opened)throw new RuntimeException('Tanggal jatuh tempo tidak boleh sebelum tanggal pembukaan.');
$coa=$this->db->get_where('accounts',array('id'=>$gl,'company_id'=>$company,'tipe'=>'asset','is_active'=>1,'allow_posting'=>1))->row();if(!$coa)throw new RuntimeException('Akun COA bank tidak valid atau bukan akun Aktiva aktif.');
$row=array('company_id'=>$company,'bank_name'=>trim((string)$this->input->post('bank_name',true)),'account_number'=>trim((string)$this->input->post('account_number',true)),'account_holder'=>trim((string)$this->input->post('account_holder',true)),'product_name'=>trim((string)$this->input->post('product_name',true)),'account_type'=>$type,'interest_rate'=>$interest,'opened_at'=>$opened,'maturity_date'=>$maturity,'currency'=>strtoupper(trim((string)$this->input->post('currency',true))),'gl_account_id'=>$gl,'is_primary'=>$this->input->post('is_primary')?1:0,'show_on_invoice'=>$this->input->post('show_on_invoice')?1:0,'updated_at'=>date('Y-m-d H:i:s'));
if($row['bank_name']===''||$row['account_number']===''||$row['account_holder']===''||$row['product_name']==='')throw new RuntimeException('Nama bank, nomor rekening, pemilik, dan produk bank wajib diisi.');
if(!$this->db->get_where('currencies',array('code'=>$row['currency'],'is_active'=>1))->row())throw new RuntimeException('Mata uang rekening tidak valid.');
$this->db->where(array('company_id'=>$company,'account_number'=>$row['account_number']));if($id)$this->db->where('id !=',$id);if($this->db->count_all_results('company_bank_accounts'))throw new RuntimeException('Nomor rekening sudah terdaftar pada perusahaan ini.');
$before=null;$this->db->trans_begin();$transactionStarted=true;
if($row['is_primary'])$this->db->where('company_id',$company)->update('company_bank_accounts',array('is_primary'=>0,'updated_at'=>date('Y-m-d H:i:s')));
if($id){$before=$this->db->get_where('company_bank_accounts',array('id'=>$id,'company_id'=>$company))->row();if(!$before)throw new RuntimeException('Rekening perusahaan tidak ditemukan.');$this->db->where(array('id'=>$id,'company_id'=>$company))->update('company_bank_accounts',$row);$bankId=$id;}else{$row['is_active']=1;$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('company_bank_accounts',$row);$bankId=(int)$this->db->insert_id();}
$after=$this->db->get_where('company_bank_accounts',array('id'=>$bankId,'company_id'=>$company))->row();$this->auditservice->record('company','company_bank_account',$bankId,$id?'update':'create',$before,$after,(int)$this->session->userdata('user_id'));if(!$this->db->trans_status())throw new RuntimeException('Rekening perusahaan gagal disimpan.');$this->db->trans_commit();$transactionStarted=false;$this->session->set_flashdata('success','Rekening perusahaan berhasil disimpan.');
}catch(Throwable$e){if($transactionStarted)$this->db->trans_rollback();$this->session->set_flashdata('error',$e->getMessage());}
redirect('companies#companyBanks');
}
public function toggle_bank($id)
{
$this->postOnly();$company=(int)$this->companycontext->id();$this->db->trans_begin();
try{$bank=$this->db->query('SELECT * FROM company_bank_accounts WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$company))->row();if(!$bank)throw new RuntimeException('Rekening perusahaan tidak ditemukan.');$update=array('is_active'=>$bank->is_active?0:1,'is_primary'=>0,'updated_at'=>date('Y-m-d H:i:s'));$this->db->where(array('id'=>$bank->id,'company_id'=>$company))->update('company_bank_accounts',$update);$after=$this->db->get_where('company_bank_accounts',array('id'=>$bank->id,'company_id'=>$company))->row();$this->auditservice->record('company','company_bank_account',$bank->id,'change_status',$bank,$after,(int)$this->session->userdata('user_id'));if(!$this->db->trans_status())throw new RuntimeException('Status rekening perusahaan gagal diperbarui.');$this->db->trans_commit();$this->session->set_flashdata('success','Status rekening perusahaan diperbarui.');}catch(Throwable$e){$this->db->trans_rollback();$this->session->set_flashdata('error',$e->getMessage());}redirect('companies#companyBanks');
}
public function bank_export()
{
$company=(int)$this->companycontext->id();$this->db->select('b.bank_name,b.account_number,b.account_holder,b.product_name,b.account_type,b.interest_rate,b.opened_at,b.maturity_date,b.currency,b.is_active,a.kode_akun,a.nama_akun')->from('company_bank_accounts b')->join('accounts a','a.id=b.gl_account_id AND a.company_id=b.company_id','left')->where('b.company_id',$company);$bank=trim((string)$this->input->get('bank',true));$type=trim((string)$this->input->get('type',true));$status=$this->input->get('status');$from=trim((string)$this->input->get('maturity_from',true));$to=trim((string)$this->input->get('maturity_to',true));if($bank!=='')$this->db->like('b.bank_name',$bank);if(in_array($type,array('savings','deposit'),true))$this->db->where('b.account_type',$type);if($status==='0'||$status==='1')$this->db->where('b.is_active',(int)$status);if($this->validDate($from))$this->db->where('b.maturity_date >=',$from);if($this->validDate($to))$this->db->where('b.maturity_date <=',$to);$rows=$this->db->order_by('b.bank_name')->order_by('b.account_number')->get()->result_array();$this->output->set_content_type('text/csv')->set_header('Content-Disposition: attachment; filename="rekening-perusahaan-'.date('Ymd').'.csv"');$out=fopen('php://output','w');fputcsv($out,array('Nama Bank','Nomor Rekening','Pemilik','Produk','Jenis','Bunga (%)','Tanggal Pembukaan','Jatuh Tempo','Mata Uang','Status','Kode COA','Nama COA'));foreach($rows as$row){$row['account_type']=$row['account_type']==='deposit'?'Deposito':'Tabungan';$row['is_active']=$row['is_active']?'Aktif':'Nonaktif';fputcsv($out,array_values($row));}fclose($out);
}
public function save_currency()
{
$this->postOnly();try{$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$symbol=trim((string)$this->input->post('symbol',true));$decimals=max(0,min(6,(int)$this->input->post('decimal_places')));if(!preg_match('/^[A-Z]{3}$/',$code)||$name==='')throw new RuntimeException('Kode mata uang harus tiga huruf dan nama wajib diisi.');$row=array('name'=>$name,'symbol'=>$symbol,'decimal_places'=>$decimals,'is_active'=>$this->input->post('is_active')?1:0);if($this->db->get_where('currencies',array('code'=>$code))->row())$this->db->where('code',$code)->update('currencies',$row);else{$row['code']=$code;$this->db->insert('currencies',$row);}$this->session->set_flashdata('success','Pengaturan mata uang berhasil disimpan.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies');
}
public function rate()
{
$this->postOnly();try{$rate=(float)$this->input->post('rate');$from=strtoupper(trim((string)$this->input->post('from_currency',true)));$to=strtoupper(trim((string)$this->input->post('to_currency',true)));if($rate<=0||$from===''||$to===''||$from===$to)throw new RuntimeException('Pasangan mata uang dan nilai kurs tidak valid.');$this->db->insert('exchange_rates',array('company_id'=>$this->companycontext->id(),'rate_date'=>$this->input->post('date'),'from_currency'=>$from,'to_currency'=>$to,'rate'=>$rate,'source'=>$this->input->post('source',true),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));$this->session->set_flashdata('success','Kurs berhasil disimpan.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies');
}
public function revalue(){try{$this->multicurrencyservice->revalue($this->input->post('date'),$this->session->userdata('user_id'));$this->session->set_flashdata('success','Revaluasi kurs berhasil diposting.');}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}redirect('companies');}
private function uploadLogo($current)
{
if(empty($_FILES['logo'])||$_FILES['logo']['error']===UPLOAD_ERR_NO_FILE)return$current;if($_FILES['logo']['error']!==UPLOAD_ERR_OK)throw new RuntimeException('Logo perusahaan gagal diunggah.');if((int)$_FILES['logo']['size']>2*1024*1024)throw new RuntimeException('Ukuran logo maksimal 2 MB.');$mime=(new finfo(FILEINFO_MIME_TYPE))->file($_FILES['logo']['tmp_name']);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp');if(!isset($allowed[$mime]))throw new RuntimeException('Logo hanya menerima JPG, PNG, atau WEBP.');$dir=FCPATH.'uploads/accounting/company/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new RuntimeException('Folder logo perusahaan tidak dapat dibuat.');$relative='uploads/accounting/company/'.bin2hex(random_bytes(16)).'.'.$allowed[$mime];if(!move_uploaded_file($_FILES['logo']['tmp_name'],FCPATH.$relative))throw new RuntimeException('Logo perusahaan tidak dapat disimpan.');return$relative;
}
private function postOnly(){if(strtoupper($this->input->method())!=='POST')show_error('Metode tidak diizinkan.',405);}
private function validDate($value){$date=DateTime::createFromFormat('Y-m-d',(string)$value);return$date&&$date->format('Y-m-d')===$value;}
}
+34 -23
View File
@@ -7,11 +7,12 @@ class Cronjob extends CI_Controller {
{
parent::__construct();
$this->load->database();
$this->load->library('AccountMappingService');
$this->load->library('PostingService');
// ONLY CLI ACCESS
// if (!$this->input->is_cli_request()) {
// exit('No direct access allowed');
// }
if (!$this->input->is_cli_request()) {
show_error('Cronjob hanya dapat dijalankan melalui CLI.', 403, 'Akses ditolak');
}
}
public function generate_insight()
@@ -358,9 +359,24 @@ class Cronjob extends CI_Controller {
// =====================================================
public function depreciation_asset()
{
// Tahap 8: satu-satunya jalur penyusutan memakai subledger yang
// idempotent, row-lock, dan pemeriksaan periode akuntansi.
$this->load->library('FixedAssetService');
$assets = $this->db->select('id')->where('lifecycle_status', 'active')->get('assets')->result();
$result = array();
foreach ($assets as $asset) {
try {
$result[$asset->id] = $this->fixedassetservice->depreciate($asset->id, date('Y-m-d'), 1, true);
} catch (Throwable $e) {
$result[$asset->id] = array('error' => $e->getMessage());
}
}
return $this->output->set_content_type('application/json')->set_output(json_encode(array('status'=>true,'assets'=>$result)));
/* Legacy engine retained below for historical reference; unreachable. */
$period = date('Y-m');
$runDate = date('Y-m-d');
$akun_beban_penyusutan = 54;
$akun_beban_penyusutan = $this->accountmappingservice->get('depreciation_expense');
$assets = $this->db
->select('id, nama_asset, nilai_perolehan, nilai_buku, nilai_residu, penyusutan_per_bulan, masa_manfaat, tanggal_perolehan, akumulasi_penyusutan, account_debit_id')
@@ -576,30 +592,25 @@ class Cronjob extends CI_Controller {
]);
$no_ref = 'DEP-' . date('YmdHis') . '-' . $a->id . '-' . str_replace('-', '', $iterMonth);
$this->db->insert('journals', [
$this->postingservice->post([
'tanggal' => $dueDateThisMonth,
'no_ref' => $no_ref,
'keterangan' => 'Penyusutan Asset ' . $a->nama_asset . ' periode ' . $iterMonth,
'ref_type' => 'assets',
'ref_id' => $a->id,
'created_by' => 1
]);
$jid = $this->db->insert_id();
$this->db->insert('journal_details', [
'journal_id' => $jid,
'account_id' => $akun_beban_penyusutan,
'debit' => $susut,
'kredit' => 0
]);
$this->db->insert('journal_details', [
'journal_id' => $jid,
'account_id' => $akun_akumulasi_penyusutan,
'debit' => 0,
'kredit' => $susut
]);
], [
[
'account_id' => $akun_beban_penyusutan,
'debit' => $susut,
'kredit' => 0
],
[
'account_id' => $akun_akumulasi_penyusutan,
'debit' => 0,
'kredit' => $susut
]
], false, false);
$success++;
$assetHasSuccess = true;
+15 -6
View File
@@ -1,12 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Customers extends CI_Controller {
class Customers extends MY_Controller {
public function __construct()
{
parent::__construct();
$this->load->model('DynamicModel', 'dm');
$this->load->library('SalesService');
if (!$this->session->userdata('logged_in')) {
redirect('auth');
@@ -15,9 +16,9 @@ class Customers extends CI_Controller {
public function index()
{
$data = ["active_menu" => "customers"];
$data = ["active_menu" => "customers", "summary" => $this->salesservice->dashboard()];
$this->load->view('partials/header', $data);
$this->load->view('customers/layout');
$this->load->view('customers/layout', $data);
$this->load->view('partials/footer');
}
@@ -73,7 +74,11 @@ class Customers extends CI_Controller {
'nama' => $this->input->post('nama'),
'alamat' => $this->input->post('alamat'),
'telp' => $this->input->post('telp'),
'email' => $this->input->post('email')
'email' => $this->input->post('email'),
'credit_limit' => max(0,(float)$this->input->post('credit_limit')),
'payment_term_days' => max(0,(int)$this->input->post('payment_term_days')),
'is_credit_blocked' => $this->input->post('is_credit_blocked') ? 1 : 0,
'tax_number' => $this->input->post('tax_number',true)
];
if (!$data['nama']) {
@@ -114,7 +119,11 @@ class Customers extends CI_Controller {
'nama' => $this->input->post('nama'),
'alamat' => $this->input->post('alamat'),
'telp' => $this->input->post('telp'),
'email' => $this->input->post('email')
'email' => $this->input->post('email'),
'credit_limit' => max(0,(float)$this->input->post('credit_limit')),
'payment_term_days' => max(0,(int)$this->input->post('payment_term_days')),
'is_credit_blocked' => $this->input->post('is_credit_blocked') ? 1 : 0,
'tax_number' => $this->input->post('tax_number',true)
];
$this->dm->setTable('customers')->update($id, $data);
@@ -149,4 +158,4 @@ class Customers extends CI_Controller {
echo json_encode(['status'=>true,'message'=>'Data berhasil dihapus']);
}
}
}
+294 -222
View File
@@ -1,82 +1,44 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Dashboard extends CI_Controller {
class Dashboard extends MY_Controller
{
public function __construct()
{
parent::__construct();
$this->load->library('session');
// Cek apakah user sudah login
if (!$this->session->userdata('logged_in')) {
redirect('auth');
}
$this->load->library('PerformanceCacheService');
}
public function index()
{
$this->db->select('
accounts.id,
accounts.kode_akun,
accounts.nama_akun,
accounts.tipe,
COALESCE(SUM(journal_details.debit),0) as debit,
COALESCE(SUM(journal_details.kredit),0) as kredit
');
$this->db->from('accounts');
$this->db->join('journal_details','accounts.id = journal_details.account_id','left');
$this->db->where('accounts.kategori','laba_rugi');
$this->db->where('accounts.is_active',1);
$this->db->group_by('accounts.id');
$this->db->order_by('accounts.kode_akun','ASC');
$rows = $this->db->get()->result();
$revenue = 0;
$expense = 0;
foreach ($rows as $r) {
// 🔥 NORMALISASI SALDO BERDASARKAN POSISI
if ($r->tipe == 'revenue') {
$saldo = $r->kredit - $r->debit;
$revenue += $saldo;
$companyId = (int) $this->companycontext->id();
$year = (int) date('Y');
$summary = $this->performancecacheservice->remember(
'dashboard:summary:' . $companyId . ':' . $year,
300,
function () use ($companyId, $year) {
return $this->financialSummary($companyId, $year);
}
);
$previous = $this->financialSummary($companyId, $year - 1);
$overview = $this->operationalOverview($companyId);
if ($r->tipe == 'expense') {
$saldo = $r->debit - $r->kredit;
$expense += $saldo;
}
}
$summary = [
'total_revenue' => $revenue,
'total_expense' => $expense,
'net_profit' => $revenue - $expense
];
$today = date('Y-m');
$ai_insight = $this->db
->select('type, severity, message, meta, created_at')
->where('period', $today)
->order_by("
CASE severity
WHEN 'critical' THEN 1
WHEN 'warning' THEN 2
WHEN 'info' THEN 3
END
", '', false)
->order_by('created_at', 'DESC')
->limit(20)
->get('ai_insights')
->result();
$data = [
"active_menu" => "dashboard",
"summary" => $summary,
"ai_insight" => $ai_insight
];
$data = array(
'active_menu' => 'dashboard',
'summary' => $summary,
'comparison' => array(
'revenue' => $this->percentageChange($summary['total_revenue'], $previous['total_revenue']),
'expense' => $this->percentageChange($summary['total_expense'], $previous['total_expense']),
'profit' => $this->percentageChange($summary['net_profit'], $previous['net_profit']),
),
'overview' => $overview,
'ai_insight' => $this->insights($companyId),
'dashboard_year' => $year,
'quick_links' => $this->quickLinks(),
'show_financial' => is_master_admin_user()
|| check_permission('reports', 'can_view')
|| check_permission('jurnal', 'can_view'),
);
$this->load->view('partials/header', $data);
$this->load->view('dashboard/layout', $data);
@@ -85,195 +47,305 @@ class Dashboard extends CI_Controller {
public function get_activity()
{
$this->db->select('activity_logs.*, users.nama');
$this->db->from('activity_logs');
$this->db->join('users','users.id = activity_logs.user_id','left');
if ($this->session->userdata('role') != 'Admin') {
$this->db->where('user_id',$this->session->userdata('user_id'));
}
$this->db->order_by('created_at','DESC');
$this->db->limit(10);
$data = $this->db->get()->result();
echo json_encode($data);
$this->activityQuery();
$rows = $this->db->order_by('activity_logs.created_at', 'DESC')->limit(10)->get()->result();
return $this->json($rows);
}
public function log_activity()
{
$data = [
"active_menu" => "dashboard"
];
$data = array(
'active_menu' => 'dashboard',
'use_datatables' => true,
);
$this->load->view('partials/header', $data);
$this->load->view('dashboard/activity_logs', $data);
$this->load->view('partials/footer');
}
public function get_activity_datatable()
{
$this->load->database();
$draw = (int) $this->input->post('draw');
$start = max(0, (int) $this->input->post('start'));
$length = (int) $this->input->post('length');
$searchInput = $this->input->post('search');
$search = is_array($searchInput) ? trim((string) ($searchInput['value'] ?? '')) : '';
$draw = $this->input->post('draw');
$start = $this->input->post('start');
$length = $this->input->post('length');
$search = $this->input->post('search')['value'] ?? '';
$this->activityQuery($search);
$recordsFiltered = (int) $this->db->count_all_results();
// =========================
// BASE QUERY
// =========================
$this->db->select('
activity_logs.*,
users.nama
');
$this->db->from('activity_logs');
$this->db->join('users', 'users.id = activity_logs.user_id', 'left');
// =========================
// SEARCH
// =========================
if (!empty($search)) {
$this->db->group_start();
$this->db->like('activity_logs.module', $search);
$this->db->or_like('activity_logs.action', $search);
$this->db->or_like('activity_logs.description', $search);
$this->db->or_like('activity_logs.status', $search);
$this->db->or_like('users.nama', $search);
$this->db->group_end();
}
// =========================
// COUNT FILTERED
// =========================
$filtered_query = clone $this->db;
$recordsFiltered = $filtered_query->count_all_results('', false);
// =========================
// ORDER
// =========================
$this->activityQuery($search);
$this->db->order_by('activity_logs.id', 'DESC');
// =========================
// LIMIT
// =========================
if ($length != -1) {
$this->db->limit($length, $start);
if ($length !== -1) {
$this->db->limit(max(1, $length), $start);
}
$rows = $this->db->get()->result();
$query = $this->db->get();
$data = $query->result();
// =========================
// TOTAL RECORD
// =========================
$recordsTotal = $this->db->count_all('activity_logs');
// =========================
// FORMAT OUTPUT
// =========================
$result = [];
foreach ($data as $row) {
$result[] = [
'created_at' => $row->created_at,
'nama' => $row->nama ?? 'System',
'module' => $row->module,
'action' => $row->action,
$this->activityQuery();
$recordsTotal = (int) $this->db->count_all_results();
$result = array();
foreach ($rows as $row) {
$result[] = array(
'created_at' => $row->created_at,
'nama' => $row->nama ?: 'System',
'module' => $row->module,
'action' => $row->action,
'description' => $row->description,
'status' => $row->status,
'ip_address' => $row->ip_address
];
'status' => $row->status,
'ip_address' => $row->ip_address,
);
}
echo json_encode([
"draw" => intval($draw),
"recordsTotal" => $recordsTotal,
"recordsFiltered" => $recordsFiltered,
"data" => $result
]);
return $this->json(array(
'draw' => $draw,
'recordsTotal' => $recordsTotal,
'recordsFiltered' => $recordsFiltered,
'data' => $result,
));
}
public function get_chart()
{
$year = $this->input->get('year');
// 🔥 DEFAULT: tahun berjalan
if (empty($year)) {
$year = date('Y');
$year = (int) $this->input->get('year');
if ($year < 2000 || $year > 2200) {
$year = (int) date('Y');
}
$this->db->select('
journals.tanggal,
accounts.tipe,
SUM(journal_details.debit) as total_debit,
SUM(journal_details.kredit) as total_kredit
');
$this->db->from('journal_details');
$this->db->join('journals', 'journals.id = journal_details.journal_id', 'left');
$this->db->join('accounts', 'accounts.id = journal_details.account_id', 'left');
$this->db->where('accounts.kategori', 'laba_rugi');
$this->db->where('accounts.is_active', 1);
$companyId = (int) $this->companycontext->id();
$rows = $this->db->query(
"SELECT MONTH(j.tanggal) month_no,
COALESCE(SUM(CASE WHEN a.tipe='revenue' THEN d.kredit-d.debit ELSE 0 END),0) revenue,
COALESCE(SUM(CASE WHEN a.tipe='expense' THEN d.debit-d.kredit ELSE 0 END),0) expense
FROM journal_details d
JOIN journals j ON j.id=d.journal_id
JOIN accounts a ON a.id=d.account_id
WHERE j.company_id=? AND j.status IN('posted','reversed')
AND a.kategori='laba_rugi' AND a.is_active=1 AND YEAR(j.tanggal)=?
GROUP BY MONTH(j.tanggal)
ORDER BY month_no",
array($companyId, $year)
)->result();
// 🔥 pakai tahun default / filter
$this->db->where('YEAR(journals.tanggal)', $year);
$this->db->group_by(['journals.tanggal', 'accounts.tipe']);
$this->db->order_by('journals.tanggal', 'ASC');
$rows = $this->db->get()->result();
$map = [];
foreach ($rows as $r) {
$date = $r->tanggal;
if (!isset($map[$date])) {
$map[$date] = [
'revenue' => 0,
'expense' => 0
];
}
if ($r->tipe == 'revenue') {
$map[$date]['revenue'] += ($r->total_kredit - $r->total_debit);
}
if ($r->tipe == 'expense') {
$map[$date]['expense'] += ($r->total_debit - $r->total_kredit);
$months = array('Jan', 'Feb', 'Mar', 'Apr', 'Mei', 'Jun', 'Jul', 'Agu', 'Sep', 'Okt', 'Nov', 'Des');
$revenue = array_fill(0, 12, 0);
$expense = array_fill(0, 12, 0);
foreach ($rows as $row) {
$index = (int) $row->month_no - 1;
if ($index >= 0 && $index < 12) {
$revenue[$index] = (float) $row->revenue;
$expense[$index] = (float) $row->expense;
}
}
$labels = [];
$revenue = [];
$expense = [];
foreach ($map as $date => $val) {
$labels[] = $date;
$revenue[] = (float)$val['revenue'];
$expense[] = (float)$val['expense'];
}
echo json_encode([
'year' => $year, // optional biar debugging enak
'labels' => $labels,
return $this->json(array(
'year' => $year,
'labels' => $months,
'revenue' => $revenue,
'expense' => $expense
]);
'expense' => $expense,
));
}
public function get_years()
{
$this->db->select('DISTINCT YEAR(tanggal) as year');
$this->db->from('journals');
$this->db->order_by('year', 'DESC');
$data = $this->db->get()->result();
echo json_encode($data);
$this->db->select('DISTINCT YEAR(tanggal) year', false)
->from('journals')
->where('company_id', (int) $this->companycontext->id())
->where_in('status', array('posted', 'reversed'))
->order_by('year', 'DESC');
$years = $this->db->get()->result();
if (!$years) {
$years = array((object) array('year' => (int) date('Y')));
}
return $this->json($years);
}
private function financialSummary($companyId, $year)
{
$row = $this->db->query(
"SELECT
COALESCE(SUM(CASE WHEN a.tipe='revenue' THEN d.kredit-d.debit ELSE 0 END),0) total_revenue,
COALESCE(SUM(CASE WHEN a.tipe='expense' THEN d.debit-d.kredit ELSE 0 END),0) total_expense
FROM journal_details d
JOIN journals j ON j.id=d.journal_id
JOIN accounts a ON a.id=d.account_id
WHERE j.company_id=? AND j.status IN('posted','reversed')
AND a.kategori='laba_rugi' AND a.is_active=1 AND YEAR(j.tanggal)=?",
array((int) $companyId, (int) $year)
)->row_array();
$revenue = (float) ($row['total_revenue'] ?? 0);
$expense = (float) ($row['total_expense'] ?? 0);
return array(
'total_revenue' => $revenue,
'total_expense' => $expense,
'net_profit' => $revenue - $expense,
);
}
private function operationalOverview($companyId)
{
$result = array(
'receivable_amount' => 0,
'receivable_count' => 0,
'receivable_overdue' => 0,
'payable_amount' => 0,
'payable_count' => 0,
'payable_overdue' => 0,
'inventory_value' => 0,
'active_items' => 0,
'pending_items' => 0,
'pending_approvals' => 0,
);
if ($this->db->table_exists('invoices')) {
$whereDeleted = $this->db->field_exists('deleted_at', 'invoices') ? ' AND deleted_at IS NULL' : '';
$row = $this->db->query(
"SELECT COUNT(*) total,
COALESCE(SUM(sisa_piutang),0) amount,
SUM(CASE WHEN jatuh_tempo<CURDATE() THEN 1 ELSE 0 END) overdue
FROM invoices
WHERE company_id=? AND workflow_status='posted' AND sisa_piutang>0.001" . $whereDeleted,
array($companyId)
)->row();
if ($row) {
$result['receivable_count'] = (int) $row->total;
$result['receivable_amount'] = (float) $row->amount;
$result['receivable_overdue'] = (int) $row->overdue;
}
}
if ($this->db->table_exists('supplier_invoices')) {
$companyFilter = $this->db->field_exists('company_id', 'supplier_invoices')
? ' AND company_id=?'
: '';
$bindings = $companyFilter === '' ? array() : array($companyId);
$row = $this->db->query(
"SELECT COUNT(*) total,
COALESCE(SUM(balance),0) amount,
SUM(CASE WHEN due_date<=CURDATE() THEN 1 ELSE 0 END) overdue
FROM supplier_invoices
WHERE status='partial' AND balance>0.001" . $companyFilter,
$bindings
)->row();
if ($row) {
$result['payable_count'] = (int) $row->total;
$result['payable_amount'] = (float) $row->amount;
$result['payable_overdue'] = (int) $row->overdue;
}
}
if ($this->db->table_exists('items')) {
$result['active_items'] = (int) $this->db
->where(array('company_id' => $companyId, 'status' => 'active'))
->count_all_results('items');
if ($this->db->table_exists('item_barcodes')) {
$pending = $this->db->query(
"SELECT COUNT(DISTINCT i.id) total
FROM items i
LEFT JOIN item_barcodes b ON b.item_id=i.id AND b.status='pending'
WHERE i.company_id=? AND (i.status='draft' OR b.id IS NOT NULL)",
array($companyId)
)->row();
$result['pending_items'] = (int) ($pending ? $pending->total : 0);
} else {
$result['pending_items'] = (int) $this->db
->where(array('company_id' => $companyId, 'status' => 'draft'))
->count_all_results('items');
}
}
if ($this->db->table_exists('inventory_ledger') && $this->db->field_exists('value', 'inventory_ledger')) {
$inventory = $this->db->query(
"SELECT COALESCE(SUM(CASE WHEN l.direction='in' THEN l.value ELSE -l.value END),0) amount
FROM inventory_ledger l JOIN items i ON i.id=l.item_id WHERE i.company_id=?",
array($companyId)
)->row();
$result['inventory_value'] = (float) ($inventory ? $inventory->amount : 0);
}
if ($this->db->table_exists('approval_requests')
&& (is_master_admin_user() || check_permission('approvals', 'can_view'))) {
$result['pending_approvals'] = (int) $this->db
->where('status', 'pending')
->count_all_results('approval_requests');
}
return $result;
}
private function insights($companyId)
{
if (!$this->db->table_exists('ai_insights')) {
return array();
}
$this->db->select('type,severity,message,meta,created_at')
->where('period', date('Y-m'));
if ($this->db->field_exists('company_id', 'ai_insights')) {
$this->db->where('company_id', $companyId);
}
return $this->db
->order_by("CASE severity WHEN 'critical' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END", '', false)
->order_by('created_at', 'DESC')
->limit(8)
->get('ai_insights')
->result();
}
private function quickLinks()
{
$definitions = array(
array('feature' => 'invoices', 'action' => 'can_create', 'label' => 'Buat Invoice', 'description' => 'Catat tagihan penjualan', 'icon' => 'bi-receipt-cutoff', 'url' => 'invoices/create'),
array('feature' => 'purchases', 'action' => 'can_create', 'label' => 'Purchase Workflow', 'description' => 'Buat permintaan pembelian', 'icon' => 'bi-cart-plus', 'url' => 'purchases'),
array('feature' => 'jurnal', 'action' => 'can_create', 'label' => 'Jurnal Umum', 'description' => 'Catat jurnal manual', 'icon' => 'bi-journal-plus', 'url' => 'jurnal'),
array('feature' => 'items', 'action' => 'can_view', 'label' => 'Scan Persediaan', 'description' => 'Periksa barcode dan stok', 'icon' => 'bi-qr-code-scan', 'url' => 'inventoryprofessional#scanner'),
);
$links = array();
foreach ($definitions as $definition) {
if (is_master_admin_user() || check_permission($definition['feature'], $definition['action'])) {
$links[] = $definition;
}
}
return $links;
}
private function activityQuery($search = '')
{
$this->db->select('activity_logs.*,users.nama')
->from('activity_logs')
->join('users', 'users.id=activity_logs.user_id', 'left');
if ($this->db->field_exists('company_id', 'activity_logs')) {
$this->db->where('activity_logs.company_id', (int) $this->companycontext->id());
} elseif (!is_master_admin_user()) {
$this->db->where('activity_logs.user_id', (int) $this->session->userdata('user_id'));
}
if ($search !== '') {
$this->db->group_start()
->like('activity_logs.module', $search)
->or_like('activity_logs.action', $search)
->or_like('activity_logs.description', $search)
->or_like('activity_logs.status', $search)
->or_like('users.nama', $search)
->group_end();
}
}
private function percentageChange($current, $previous)
{
$previous = (float) $previous;
if (abs($previous) < 0.01) {
return null;
}
return round((((float) $current - $previous) / abs($previous)) * 100, 1);
}
private function json($payload)
{
return $this->output
->set_content_type('application/json', 'utf-8')
->set_output(json_encode($payload, JSON_UNESCAPED_UNICODE));
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/**
* One-way legacy database cutover utility.
*
* This controller is deliberately CLI-only. Credentials are read from the
* environment and are never stored in the application source.
*/
class Databasesync extends CI_Controller
{
public function __construct()
{
parent::__construct();
if (!$this->input->is_cli_request()) {
show_error('Database sync hanya dapat dijalankan melalui CLI.', 403, 'Akses ditolak');
}
if (!filter_var(app_env('DBSYNC_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN)) {
fwrite(STDERR, "DBSYNC_ENABLED belum diaktifkan.\n");
exit(2);
}
$this->load->library('DatabaseSyncService');
}
public function check()
{
$this->render($this->databasesyncservice->check());
}
public function snapshot()
{
$this->render($this->databasesyncservice->snapshot());
}
public function bootstrap()
{
$this->render($this->databasesyncservice->bootstrap());
}
public function verify()
{
$this->render($this->databasesyncservice->verify());
}
private function render($result)
{
echo json_encode($result, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . PHP_EOL;
}
}
+338
View File
@@ -0,0 +1,338 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Dbcompare extends MY_Admin_Controller
{
private $sourceConfig;
private $targetConfig;
public function __construct()
{
parent::__construct();
if (!filter_var(app_env('DBCOMPARE_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN)) {
show_404();
}
$this->sourceConfig = $this->readConnectionConfig('DBCOMPARE_SOURCE');
$this->targetConfig = $this->readConnectionConfig('DBCOMPARE_TARGET');
}
private function readConnectionConfig($prefix)
{
$config = array(
'host' => app_env($prefix . '_HOST', ''),
'port' => (int) app_env($prefix . '_PORT', 3306),
'user' => app_env($prefix . '_USERNAME', ''),
'pass' => app_env($prefix . '_PASSWORD', ''),
'db' => app_env($prefix . '_DATABASE', '')
);
foreach (array('host', 'port', 'user', 'pass', 'db') as $key) {
if (empty($config[$key])) {
show_error('Konfigurasi Dbcompare belum lengkap.', 503);
}
}
return $config;
}
public function sql()
{
$result = $this->buildSqlDiff();
header('Content-Type: text/plain; charset=utf-8');
echo $result['sql'];
}
public function sql_download()
{
$result = $this->buildSqlDiff();
$filename = 'sync_structure_' . date('Ymd_His') . '.sql';
header('Content-Type: application/sql; charset=utf-8');
header('Content-Disposition: attachment; filename="' . $filename . '"');
echo $result['sql'];
}
private function buildSqlDiff()
{
$source = $this->connectMysqli($this->sourceConfig);
$target = $this->connectMysqli($this->targetConfig);
$sqlLines = array();
$sqlLines[] = '-- ==================================================';
$sqlLines[] = '-- AUTO GENERATED SQL - STRUCTURE SYNC';
$sqlLines[] = '-- Source DB : ' . $this->sourceConfig['db'] . ' (' . $this->sourceConfig['host'] . ':' . $this->sourceConfig['port'] . ')';
$sqlLines[] = '-- Target DB : ' . $this->targetConfig['db'] . ' (' . $this->targetConfig['host'] . ':' . $this->targetConfig['port'] . ')';
$sqlLines[] = '-- Generated : ' . date('Y-m-d H:i:s');
$sqlLines[] = '-- Notes : Hanya struktur (table/column/primary key/index), tanpa data';
$sqlLines[] = '-- ==================================================';
$sqlLines[] = '';
$sourceTables = $this->getTables($source, $this->sourceConfig['db']);
$targetTables = $this->getTables($target, $this->targetConfig['db']);
foreach ($sourceTables as $tableName) {
$safeTable = $this->escapeIdentifier($tableName);
if (!in_array($tableName, $targetTables, true)) {
$createSql = $this->getCreateTableSql($source, $tableName);
$sqlLines[] = '-- --------------------------------------------------';
$sqlLines[] = '-- CREATE TABLE: ' . $tableName;
$sqlLines[] = '-- --------------------------------------------------';
$sqlLines[] = $createSql . ';';
$sqlLines[] = '';
continue;
}
$sqlLines[] = '-- --------------------------------------------------';
$sqlLines[] = '-- ALTER TABLE: ' . $tableName;
$sqlLines[] = '-- --------------------------------------------------';
$sourceColumns = $this->getColumns($source, $this->sourceConfig['db'], $tableName);
$targetColumns = $this->getColumns($target, $this->targetConfig['db'], $tableName);
// Column ADD / MODIFY
foreach ($sourceColumns as $columnName => $sourceCol) {
if (!isset($targetColumns[$columnName])) {
$sqlLines[] = 'ALTER TABLE ' . $safeTable . ' ADD COLUMN ' . $this->buildColumnDefinition($sourceCol) . ';';
continue;
}
$targetCol = $targetColumns[$columnName];
if (!$this->isSameColumn($sourceCol, $targetCol)) {
$sqlLines[] = 'ALTER TABLE ' . $safeTable . ' MODIFY COLUMN ' . $this->buildColumnDefinition($sourceCol) . ';';
}
}
// Primary key sync
$sourcePk = $this->getPrimaryKeyColumns($source, $this->sourceConfig['db'], $tableName);
$targetPk = $this->getPrimaryKeyColumns($target, $this->targetConfig['db'], $tableName);
if ($sourcePk !== $targetPk) {
if (!empty($targetPk)) {
$sqlLines[] = 'ALTER TABLE ' . $safeTable . ' DROP PRIMARY KEY;';
}
if (!empty($sourcePk)) {
$pkCols = array();
foreach ($sourcePk as $pkCol) {
$pkCols[] = $this->escapeIdentifier($pkCol);
}
$sqlLines[] = 'ALTER TABLE ' . $safeTable . ' ADD PRIMARY KEY (' . implode(', ', $pkCols) . ');';
}
}
// Secondary indexes sync (add missing / different composition)
$sourceIndexes = $this->getIndexes($source, $this->sourceConfig['db'], $tableName);
$targetIndexes = $this->getIndexes($target, $this->targetConfig['db'], $tableName);
foreach ($sourceIndexes as $idxName => $sourceIdx) {
if ($idxName === 'PRIMARY') {
continue;
}
$needCreate = false;
if (!isset($targetIndexes[$idxName])) {
$needCreate = true;
} else {
$targetIdx = $targetIndexes[$idxName];
if (!$this->isSameIndex($sourceIdx, $targetIdx)) {
$sqlLines[] = 'ALTER TABLE ' . $safeTable . ' DROP INDEX ' . $this->escapeIdentifier($idxName) . ';';
$needCreate = true;
}
}
if ($needCreate) {
$idxCols = array();
foreach ($sourceIdx['columns'] as $colName) {
$idxCols[] = $this->escapeIdentifier($colName);
}
$idxType = $sourceIdx['non_unique'] == 0 ? 'ADD UNIQUE INDEX ' : 'ADD INDEX ';
$sqlLines[] = 'ALTER TABLE ' . $safeTable . ' ' . $idxType . $this->escapeIdentifier($idxName) . ' (' . implode(', ', $idxCols) . ');';
}
}
$sqlLines[] = '';
}
$source->close();
$target->close();
return array('sql' => implode("\n", $sqlLines));
}
private function connectMysqli($config)
{
$conn = @new mysqli($config['host'], $config['user'], $config['pass'], $config['db'], (int)$config['port']);
if ($conn->connect_errno) {
show_error('Gagal konek DB ' . $config['db'] . ': ' . $conn->connect_error, 500);
}
$conn->set_charset('utf8');
return $conn;
}
private function getTables($conn, $schema)
{
$sql = "SELECT TABLE_NAME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = ?
AND TABLE_TYPE = 'BASE TABLE'
ORDER BY TABLE_NAME";
$stmt = $conn->prepare($sql);
$stmt->bind_param('s', $schema);
$stmt->execute();
$res = $stmt->get_result();
$tables = array();
while ($row = $res->fetch_assoc()) {
$tables[] = $row['TABLE_NAME'];
}
$stmt->close();
return $tables;
}
private function getCreateTableSql($conn, $tableName)
{
$sql = 'SHOW CREATE TABLE ' . $this->escapeIdentifier($tableName);
$res = $conn->query($sql);
if (!$res) {
return '-- ERROR: gagal ambil CREATE TABLE untuk ' . $tableName;
}
$row = $res->fetch_assoc();
return isset($row['Create Table']) ? $row['Create Table'] : '-- ERROR: format SHOW CREATE TABLE tidak valid';
}
private function getColumns($conn, $schema, $tableName)
{
$sql = "SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA, COLUMN_COMMENT, CHARACTER_SET_NAME, COLLATION_NAME
FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
ORDER BY ORDINAL_POSITION";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $schema, $tableName);
$stmt->execute();
$res = $stmt->get_result();
$columns = array();
while ($row = $res->fetch_assoc()) {
$columns[$row['COLUMN_NAME']] = $row;
}
$stmt->close();
return $columns;
}
private function getPrimaryKeyColumns($conn, $schema, $tableName)
{
$sql = "SELECT kcu.COLUMN_NAME
FROM INFORMATION_SCHEMA.TABLE_CONSTRAINTS tc
JOIN INFORMATION_SCHEMA.KEY_COLUMN_USAGE kcu
ON tc.CONSTRAINT_NAME = kcu.CONSTRAINT_NAME
AND tc.TABLE_SCHEMA = kcu.TABLE_SCHEMA
AND tc.TABLE_NAME = kcu.TABLE_NAME
WHERE tc.TABLE_SCHEMA = ?
AND tc.TABLE_NAME = ?
AND tc.CONSTRAINT_TYPE = 'PRIMARY KEY'
ORDER BY kcu.ORDINAL_POSITION";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $schema, $tableName);
$stmt->execute();
$res = $stmt->get_result();
$pk = array();
while ($row = $res->fetch_assoc()) {
$pk[] = $row['COLUMN_NAME'];
}
$stmt->close();
return $pk;
}
private function getIndexes($conn, $schema, $tableName)
{
$sql = "SELECT INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME
FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = ?
AND TABLE_NAME = ?
ORDER BY INDEX_NAME, SEQ_IN_INDEX";
$stmt = $conn->prepare($sql);
$stmt->bind_param('ss', $schema, $tableName);
$stmt->execute();
$res = $stmt->get_result();
$indexes = array();
while ($row = $res->fetch_assoc()) {
$idxName = $row['INDEX_NAME'];
if (!isset($indexes[$idxName])) {
$indexes[$idxName] = array(
'non_unique' => (int)$row['NON_UNIQUE'],
'columns' => array()
);
}
$indexes[$idxName]['columns'][] = $row['COLUMN_NAME'];
}
$stmt->close();
return $indexes;
}
private function buildColumnDefinition($col)
{
$def = $this->escapeIdentifier($col['COLUMN_NAME']) . ' ' . $col['COLUMN_TYPE'];
$def .= ($col['IS_NULLABLE'] === 'NO') ? ' NOT NULL' : ' NULL';
if ($col['COLUMN_DEFAULT'] !== null) {
if (strtoupper($col['COLUMN_DEFAULT']) === 'CURRENT_TIMESTAMP') {
$def .= ' DEFAULT CURRENT_TIMESTAMP';
} else {
$def .= " DEFAULT '" . $this->escapeStringValue($col['COLUMN_DEFAULT']) . "'";
}
} elseif ($col['IS_NULLABLE'] === 'YES') {
$def .= ' DEFAULT NULL';
}
if (!empty($col['EXTRA'])) {
$def .= ' ' . strtoupper($col['EXTRA']);
}
if (!empty($col['COLUMN_COMMENT'])) {
$def .= " COMMENT '" . $this->escapeStringValue($col['COLUMN_COMMENT']) . "'";
}
return $def;
}
private function isSameColumn($a, $b)
{
return (
strtolower((string)$a['COLUMN_TYPE']) === strtolower((string)$b['COLUMN_TYPE']) &&
(string)$a['IS_NULLABLE'] === (string)$b['IS_NULLABLE'] &&
(string)$a['COLUMN_DEFAULT'] === (string)$b['COLUMN_DEFAULT'] &&
strtolower((string)$a['EXTRA']) === strtolower((string)$b['EXTRA']) &&
(string)$a['COLUMN_COMMENT'] === (string)$b['COLUMN_COMMENT']
);
}
private function isSameIndex($a, $b)
{
return (
(int)$a['non_unique'] === (int)$b['non_unique'] &&
implode(',', $a['columns']) === implode(',', $b['columns'])
);
}
private function escapeIdentifier($name)
{
return '`' . str_replace('`', '``', $name) . '`';
}
private function escapeStringValue($str)
{
return str_replace("'", "''", (string)$str);
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Dbmonitor extends CI_Controller
{
public function run(){if(!$this->input->is_cli_request())show_404();$start=microtime(true);$this->db->reconnect();$connection=(microtime(true)-$start)*1000;$start=microtime(true);$this->db->query('SELECT 1')->row();$query=(microtime(true)-$start)*1000;$statusRows=$this->db->query("SHOW GLOBAL STATUS WHERE Variable_name IN('Threads_connected','Threads_running','Slow_queries')")->result();$status=array();foreach($statusRows as$r)$status[$r->Variable_name]=(int)$r->Value;$size=(float)$this->db->query("SELECT COALESCE(SUM(data_length+index_length)/1024/1024,0) size_mb FROM information_schema.tables WHERE table_schema=DATABASE()")->row()->size_mb;$health=$connection>500||$query>500?'critical':($connection>150||$query>150?'warning':'healthy');$message='Connection '.round($connection,2).' ms; query '.round($query,2).' ms';$this->db->insert('database_health_metrics',array('checked_at'=>date('Y-m-d H:i:s'),'connection_ms'=>$connection,'query_ms'=>$query,'threads_connected'=>$status['Threads_connected']??null,'threads_running'=>$status['Threads_running']??null,'slow_queries'=>$status['Slow_queries']??null,'db_size_mb'=>$size,'status'=>$health,'message'=>$message));echo json_encode(array('status'=>$health,'connection_ms'=>round($connection,3),'query_ms'=>round($query,3),'db_size_mb'=>round($size,2),'threads_connected'=>$status['Threads_connected']??null,'threads_running'=>$status['Threads_running']??null,'slow_queries'=>$status['Slow_queries']??null),JSON_PRETTY_PRINT).PHP_EOL;}
}
+85 -52
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Employees extends CI_Controller {
class Employees extends MY_Controller {
public function __construct()
{
@@ -23,7 +23,13 @@ class Employees extends CI_Controller {
public function index()
{
$data = [
"active_menu" => "employees"
"active_menu" => "employees",
"can_manage_all_salary" => $this->canManageAllSalary(),
"employee_permissions" => array(
'create'=>$this->canEmployee('can_create'),
'update'=>$this->canEmployee('can_update'),
'delete'=>$this->canEmployee('can_delete')
)
];
$data['departments'] = $this->db
@@ -55,7 +61,7 @@ class Employees extends CI_Controller {
{
try {
$data = $this->db
$query = $this->db
->select("
e.*,
@@ -83,9 +89,10 @@ class Employees extends CI_Controller {
'left'
)
->order_by('e.department_id','ASC')
->get()
->result();
->order_by('e.department_id','ASC');
if ($this->db->field_exists('company_id','k_employees')) $query->where('e.company_id',(int)$this->session->userdata('company_id'));
$data = $query->get()->result();
foreach ($data as $employee) $this->maskRestrictedSalary($employee);
return $this->json([
'status' => true,
@@ -137,7 +144,9 @@ class Employees extends CI_Controller {
'left'
)
->where('e.id',$id)
->where('e.id',$id);
if ($this->db->field_exists('company_id','k_employees')) $this->db->where('e.company_id',(int)$this->session->userdata('company_id'));
$data = $this->db
->get()
->row();
@@ -149,6 +158,7 @@ class Employees extends CI_Controller {
]);
}
$this->maskRestrictedSalary($data);
return $this->json([
'status' => true,
'data' => $data
@@ -194,6 +204,7 @@ class Employees extends CI_Controller {
$data['employee_code'] = $employee_code;
$data['photo'] = $photo;
if ($this->db->field_exists('company_id','k_employees')) $data['company_id']=(int)$this->session->userdata('company_id');
$this->db->insert('k_employees',$data);
$employee_id = $this->db->insert_id();
@@ -238,6 +249,10 @@ class Employees extends CI_Controller {
]);
}
if (!empty($employee->salary_restricted) && !$this->canManageAllSalary()) {
return $this->json(['status'=>false,'message'=>'Gaji karyawan ini bersifat terbatas. Anda memerlukan permission Manage All Gaji untuk mengubah datanya.']);
}
$data = $this->prepareData();
if (!empty($_FILES['photo_file']['name'])) {
@@ -299,6 +314,10 @@ class Employees extends CI_Controller {
]);
}
if (!empty($employee->salary_restricted) && !$this->canManageAllSalary()) {
return $this->json(['status'=>false,'message'=>'Karyawan dengan gaji terbatas hanya dapat dihapus oleh pengguna dengan permission Manage All Gaji.']);
}
if (
!empty($employee->photo)
&&
@@ -388,80 +407,94 @@ class Employees extends CI_Controller {
// =========================================================
private function prepareData()
{
return [
$nullableText = function ($value) {
$value = trim((string) $value);
return $value === '' || in_array(strtolower($value), array('undefined', 'null'), true)
? null
: $value;
};
$nullableId = function ($value) {
$value = (int) $value;
return $value > 0 ? $value : null;
};
'nik' => trim($this->input->post('nik')),
$data = [
'nik' => $nullableText($this->input->post('nik')),
'full_name' => trim(
$this->input->post('full_name')
),
'gender' => $this->input->post('gender'),
'gender' => $nullableText($this->input->post('gender')),
'birth_place' => trim(
$this->input->post('birth_place')
),
'birth_place' => $nullableText($this->input->post('birth_place')),
'birth_date' => $this->input->post('birth_date'),
'birth_date' => $nullableText($this->input->post('birth_date')),
'phone' => trim(
$this->input->post('phone')
),
'phone' => $nullableText($this->input->post('phone')),
'email' => trim(
$this->input->post('email')
),
'email' => $nullableText($this->input->post('email')),
'address' => trim(
$this->input->post('address')
),
'address' => $nullableText($this->input->post('address')),
'join_date' => $this->input->post('join_date'),
'resign_date' => $this->input->post('resign_date'),
'resign_date' => $nullableText($this->input->post('resign_date')),
'employment_status' => $this->input->post('employment_status'),
'marital_status' => $this->input->post('marital_status'),
'religion' => trim(
$this->input->post('religion')
),
'religion' => $nullableText($this->input->post('religion')),
'department_id' => $this->input->post('department_id'),
'department_id' => $nullableId($this->input->post('department_id')),
'position_id' => $this->input->post('position_id'),
'position_id' => $nullableId($this->input->post('position_id')),
'branch_id' => $this->input->post('branch_id'),
'branch_id' => $nullableId($this->input->post('branch_id')),
'basic_salary' => $this->input->post('basic_salary') ?: 0,
'bank_name' => $nullableText($this->input->post('bank_name')),
'bank_name' => trim(
$this->input->post('bank_name')
),
'bank_account_number' => $nullableText($this->input->post('bank_account_number')),
'bank_account_number' => trim(
$this->input->post('bank_account_number')
),
'bank_account_name' => $nullableText($this->input->post('bank_account_name')),
'bank_account_name' => trim(
$this->input->post('bank_account_name')
),
'bpjs_kesehatan_number' => $nullableText($this->input->post('bpjs_kesehatan_number')),
'bpjs_kesehatan_number' => trim(
$this->input->post('bpjs_kesehatan_number')
),
'bpjs_ketenagakerjaan_number' => $nullableText($this->input->post('bpjs_ketenagakerjaan_number')),
'bpjs_ketenagakerjaan_number' => trim(
$this->input->post('bpjs_ketenagakerjaan_number')
),
'fingerprint_user_id' => $nullableText($this->input->post('fingerprint_user_id')),
'fingerprint_user_id' => trim(
$this->input->post('fingerprint_user_id')
),
'is_active' => $this->input->post('is_active') ?: 1
'is_active' => $this->input->post('is_active') === '0' ? 0 : 1
];
if ($this->db->field_exists('salary_restricted','k_employees') && $this->canManageAllSalary()) {
$data['salary_restricted'] = $this->input->post('salary_restricted') ? 1 : 0;
}
return $data;
}
private function canManageAllSalary()
{
return is_master_admin_user() || check_permission('payroll','can_manage_all_salary');
}
private function canEmployee($action)
{
return $this->session->userdata('role') === 'Admin'
|| is_master_admin_user()
|| check_permission('hr_employees',$action)
|| check_permission('employees',$action);
}
private function maskRestrictedSalary($employee)
{
if (!$employee || empty($employee->salary_restricted) || $this->canManageAllSalary()) return;
$employee->basic_salary = null;
$employee->bank_name = null;
$employee->bank_account_number = null;
$employee->bank_account_name = null;
$employee->salary_hidden = true;
}
// =========================================================
@@ -473,4 +506,4 @@ class Employees extends CI_Controller {
->set_content_type('application/json')
->set_output(json_encode($data));
}
}
}
@@ -0,0 +1,34 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Read-only integrity checker: php index.php fixedassetcheck */
class Fixedassetcheck extends CI_Controller
{
public function index()
{
if (!is_cli()) show_404();
$checks=array();
$checks['duplicate_asset_document']=$this->scalar("SELECT COUNT(*) FROM (SELECT company_id,document_no FROM assets GROUP BY company_id,document_no HAVING COUNT(*)>1)x");
$checks['duplicate_event_idempotency']=$this->scalar("SELECT COUNT(*) FROM (SELECT idempotency_key FROM asset_events WHERE idempotency_key IS NOT NULL GROUP BY idempotency_key HAVING COUNT(*)>1)x");
$checks['capitalized_without_event']=$this->scalar("SELECT COUNT(*) FROM assets a WHERE a.workflow_status='capitalized' AND NOT EXISTS(SELECT 1 FROM asset_events e WHERE e.asset_id=a.id AND e.company_id=a.company_id AND e.event_type IN('acquisition','capitalization'))");
$checks['active_below_residual']=$this->scalar("SELECT COUNT(*) FROM assets WHERE lifecycle_status IN('active','fully_depreciated') AND nilai_buku+0.005<nilai_residu");
$checks['duplicate_depreciation_period']=$this->scalar("SELECT COUNT(*) FROM (SELECT company_id,asset_id,period FROM asset_depreciation_schedule GROUP BY company_id,asset_id,period HAVING COUNT(*)>1)x");
$checks['unbalanced_asset_journals']=$this->scalar("SELECT COUNT(*) FROM (SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type LIKE 'asset_%' AND j.status='posted' GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>0.005)x");
$checks['cross_company_asset_attachment']=$this->scalar("SELECT COUNT(*) FROM transaction_attachments t JOIN assets a ON t.entity_type='asset' AND t.entity_id=a.id WHERE t.company_id IS NULL OR t.company_id<>a.company_id");
$checks['warehouse_capitalization_without_ledger']=$this->scalar("SELECT COUNT(*) FROM assets a WHERE a.source_type='warehouse' AND a.workflow_status='capitalized' AND a.idempotency_key NOT LIKE 'LEGACY-ASSET-%' AND NOT EXISTS(SELECT 1 FROM inventory_ledger l WHERE l.document_type='asset_capitalization' AND l.document_id=a.id)");
$checks['invalid_asset_posting_account']=$this->scalar("SELECT COUNT(*) FROM assets a LEFT JOIN accounts ac ON ac.id=a.asset_account_id AND ac.company_id=a.company_id AND ac.is_active=1 AND ac.allow_posting=1 WHERE a.workflow_status IN('draft','submitted','approved') AND (a.asset_account_id IS NULL OR ac.id IS NULL)");
$checks['invalid_accumulated_account_scope']=$this->scalar("SELECT COUNT(*) FROM assets a LEFT JOIN accounts ac ON ac.id=a.accumulated_depreciation_account_id AND ac.company_id=a.company_id WHERE a.workflow_status IN('draft','submitted','approved','capitalized') AND a.idempotency_key NOT LIKE 'LEGACY-ASSET-%' AND (a.accumulated_depreciation_account_id IS NULL OR ac.id IS NULL)");
$checks['invalid_expense_account_scope']=$this->scalar("SELECT COUNT(*) FROM assets a LEFT JOIN accounts ac ON ac.id=a.depreciation_expense_account_id AND ac.company_id=a.company_id WHERE a.workflow_status IN('draft','submitted','approved','capitalized') AND a.idempotency_key NOT LIKE 'LEGACY-ASSET-%' AND (a.depreciation_expense_account_id IS NULL OR ac.id IS NULL)");
$fail=0;foreach($checks as$name=>$count){$ok=(int)$count===0;if(!$ok)$fail++;echo($ok?'[OK] ':'[WARN] ').$name.': '.$count.PHP_EOL;}
$totals=$this->db->query("SELECT COUNT(*) total,COALESCE(SUM(IF(workflow_status='capitalized' AND lifecycle_status NOT IN('disposed','lost','damaged'),nilai_perolehan,0)),0) cost,COALESCE(SUM(IF(workflow_status='capitalized' AND lifecycle_status NOT IN('disposed','lost','damaged'),akumulasi_penyusutan,0)),0) accumulated FROM assets")->row();
echo 'Assets: '.$totals->total.' | Cost: '.$totals->cost.' | Accumulated: '.$totals->accumulated.PHP_EOL;
$statusColumn=$this->db->query("SHOW COLUMNS FROM assets WHERE Field='status'")->row();echo '[INFO] assets.status: '.($statusColumn->Type??'missing').PHP_EOL;
$this->load->model('FixedAssetModel','fixedasset');
foreach($this->db->select('id')->get('companies')->result() as$company){
$this->fixedasset->dashboard($company->id);$this->fixedasset->categories($company->id);$this->fixedasset->locations($company->id);$this->fixedasset->opnames($company->id);$this->fixedasset->registerPage($company->id,array(),0,5,'');
echo '[OK] model_query_smoke_company_'.$company->id.PHP_EOL;
}
echo $fail?'Integrity warnings: '.$fail.PHP_EOL:'Fixed asset integrity checks passed.'.PHP_EOL;
}
private function scalar($sql){return(int)$this->db->query($sql)->row_array()['COUNT(*)'];}
}
+100
View File
@@ -0,0 +1,100 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Fixedassets extends MY_Controller
{
public function __construct()
{
parent::__construct();
$this->load->library(array('FixedAssetService','CompanyContext'));
$this->load->model('FixedAssetModel','fixedasset');
}
private function uid(){return(int)$this->session->userdata('user_id');}
private function company(){return(int)$this->companycontext->id();}
private function master(){return$this->session->userdata('role')==='Admin';}
private function can($action){return$this->master()||check_permission('asset',$action);}
private function permissions(){return array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export'));}
private function postOnly(){if(strtoupper($this->input->method())!=='POST')show_404();}
private function action(callable$callback,$message){$this->postOnly();try{$result=$callback();return json_response(true,$message,array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);}}
private function money($value){$value=preg_replace('/[^0-9,.-]/','',(string)$value);if(strpos($value,',')!==false){$value=str_replace('.','',$value);$value=str_replace(',','.',$value);}elseif(preg_match('/^-?\d{1,3}(\.\d{3})+$/',$value)){$value=str_replace('.','',$value);}return round((float)$value,2);}
public function index()
{
$data=array('active_menu'=>'fixed_assets','summary'=>$this->fixedasset->dashboard($this->company()),'categories'=>$this->fixedasset->categories($this->company()),'locations'=>$this->fixedasset->locations($this->company()),'accounts'=>$this->fixedasset->accounts($this->company()),'opnames'=>$this->fixedasset->opnames($this->company()),'asset_permissions'=>$this->permissions());
$this->load->view('partials/header',$data);$this->load->view('fixed_assets/index',$data);$this->load->view('partials/footer');
}
public function data()
{
$draw=(int)$this->input->get('draw');$start=max(0,(int)$this->input->get('start'));$length=min(100,max(10,(int)$this->input->get('length')));$search=$this->input->get('search');$search=is_array($search)?trim((string)($search['value']??'')):'';$filters=array('category_id'=>$this->input->get('category_id'),'lokasi_asset_id'=>$this->input->get('location_id'),'source_type'=>$this->input->get('source_type'),'workflow_status'=>$this->input->get('workflow_status'),'lifecycle_status'=>$this->input->get('lifecycle_status'),'responsible_employee_id'=>$this->input->get('responsible_employee_id'),'date_from'=>$this->input->get('date_from'),'date_to'=>$this->input->get('date_to'));$page=$this->fixedasset->registerPage($this->company(),$filters,$start,$length,$search);return$this->output->set_content_type('application/json')->set_output(json_encode(array('draw'=>$draw,'recordsTotal'=>$page['total'],'recordsFiltered'=>$page['filtered'],'data'=>$page['rows'])));
}
public function detail($id)
{
$asset=$this->fixedasset->detail((int)$id,$this->company());if(!$asset)return json_response(false,'Aset tidak ditemukan.',array(),404);foreach($asset->attachments as$file){$file->preview_url=base_url('accountingattachments/preview/'.$file->id);$file->download_url=base_url('accountingattachments/download/'.$file->id);}return json_response(true,'Detail aset.',array('asset'=>$asset,'permissions'=>$this->permissions()));
}
private function draftInput()
{
return array('nama_asset'=>trim((string)$this->input->post('nama_asset',true)),'tanggal_perolehan'=>$this->input->post('tanggal_perolehan',true),'capitalization_date'=>$this->input->post('capitalization_date',true),'category_id'=>(int)$this->input->post('category_id'),'lokasi_asset_id'=>(int)$this->input->post('lokasi_asset_id'),'responsible_employee_id'=>(int)$this->input->post('responsible_employee_id'),'source_type'=>$this->input->post('source_type',true),'source_id'=>(int)$this->input->post('source_id'),'source_reference_no'=>$this->input->post('source_reference_no',true),'supplier_id'=>(int)$this->input->post('supplier_id'),'source_journal_mode'=>$this->input->post('source_journal_mode',true),'source_journal_id'=>(int)$this->input->post('source_journal_id'),'item_id'=>(int)$this->input->post('item_id'),'barcode_id'=>(int)$this->input->post('barcode_id'),'warehouse_id'=>(int)$this->input->post('warehouse_id'),'bin_id'=>(int)$this->input->post('bin_id'),'qty'=>$this->money($this->input->post('qty')),'nilai_perolehan'=>$this->money($this->input->post('nilai_perolehan')),'nilai_residu'=>$this->money($this->input->post('nilai_residu')),'masa_manfaat'=>(int)$this->input->post('masa_manfaat'),'depreciation_method'=>$this->input->post('depreciation_method',true),'asset_account_id'=>(int)$this->input->post('asset_account_id'),'accumulated_depreciation_account_id'=>(int)$this->input->post('accumulated_depreciation_account_id'),'depreciation_expense_account_id'=>(int)$this->input->post('depreciation_expense_account_id'),'account_kredit_id'=>(int)$this->input->post('account_kredit_id'),'department_id'=>(int)$this->input->post('department_id'),'project_id'=>(int)$this->input->post('project_id'),'cost_center_id'=>(int)$this->input->post('cost_center_id'),'keterangan'=>$this->input->post('keterangan',true),'idempotency_key'=>$this->input->post('idempotency_key',true));
}
public function save()
{
$id=(int)$this->input->post('id');$this->requirePermission('asset',$id?'can_update':'can_create');return$this->action(function()use($id){return$id?$this->fixedassetservice->updateDraft($id,$this->draftInput(),$this->uid()):$this->fixedassetservice->createDraft($this->draftInput(),$this->uid());},'Draft perolehan aset berhasil disimpan.');
}
public function submit($id){$this->requirePermission('asset','can_create');return$this->action(fn()=>$this->fixedassetservice->submit($id,$this->uid()),'Dokumen aset berhasil diajukan.');}
public function approve($id){$this->requirePermission('asset','can_approve');return$this->action(fn()=>$this->fixedassetservice->approve($id,$this->uid(),$this->master()),'Dokumen aset berhasil disetujui.');}
public function reject($id){$this->requirePermission('asset','can_approve');return$this->action(fn()=>$this->fixedassetservice->reject($id,$this->input->post('reason',true),$this->uid(),$this->master()),'Dokumen aset ditolak.');}
public function reopen($id){$this->requirePermission('asset','can_update');return$this->action(fn()=>$this->fixedassetservice->reopenRejected($id,$this->uid()),'Dokumen dikembalikan menjadi draft.');}
public function capitalize($id){$this->requirePermission('asset','can_post');return$this->action(fn()=>$this->fixedassetservice->capitalize($id,$this->input->post('date',true),$this->uid()),'Aset berhasil dikapitalisasi.');}
public function depreciate($id){$this->requirePermission('asset','can_post');return$this->action(fn()=>$this->fixedassetservice->depreciate($id,$this->input->post('through_date',true),$this->uid(),true),'Penyusutan aset berhasil diproses.');}
public function mutate($id){$this->requirePermission('asset','can_post');return$this->action(fn()=>$this->fixedassetservice->mutate($id,$this->input->post('type',true),$this->input->post('date',true),$this->money($this->input->post('amount')),(int)$this->input->post('account_id'),$this->input->post('notes',true),$this->uid()),'Mutasi nilai aset berhasil diposting.');}
public function transfer($id){$this->requirePermission('asset','can_update');return$this->action(fn()=>$this->fixedassetservice->transfer($id,$this->input->post('date',true),(int)$this->input->post('location_id'),$this->input->post('notes',true),$this->uid()),'Lokasi aset berhasil dipindahkan.');}
public function responsibility($id){$this->requirePermission('asset','can_update');return$this->action(fn()=>$this->fixedassetservice->changeResponsibility($id,$this->input->post('date',true),(int)$this->input->post('employee_id'),$this->input->post('notes',true),$this->uid()),'Penanggung jawab aset berhasil diperbarui.');}
public function maintenance($id){$this->requirePermission('asset','can_update');return$this->action(fn()=>$this->fixedassetservice->maintenance($id,array('date'=>$this->input->post('date',true),'vendor'=>$this->input->post('vendor',true),'description'=>$this->input->post('description',true),'cost'=>$this->money($this->input->post('cost')),'next_due_date'=>$this->input->post('next_due_date',true),'idempotency_key'=>$this->input->post('idempotency_key',true)),$this->uid()),'Maintenance aset berhasil dicatat.');}
public function reverse($eventId){$this->requirePermission('asset','can_post');return$this->action(fn()=>$this->fixedassetservice->reverseEvent($eventId,$this->input->post('date',true),$this->input->post('reason',true),$this->uid()),'Event aset berhasil direversal.');}
public function lookup_inventory()
{
$page=max(1,(int)$this->input->get('page'));$limit=20;$rows=$this->fixedasset->availableInventory($this->company(),$this->input->get('q',true),$page,$limit);$more=count($rows)>$limit;if($more)array_pop($rows);$result=array();foreach($rows as$row)$result[]=array('id'=>$row->barcode_id,'text'=>$row->kode_detail.' - '.$row->nama_barang.' | '.$row->barcode.($row->serial_number?' / SN '.$row->serial_number:'').' | '.$row->warehouse_name.' | tersedia '.$this->qty($row->available_qty),'data'=>$row);return json_response(true,'Barang gudang tersedia.',array('results'=>$result,'pagination'=>array('more'=>$more)));
}
public function lookup_employees()
{
$page=max(1,(int)$this->input->get('page'));$limit=20;$rows=$this->fixedasset->employees($this->company(),$this->input->get('q',true),$page,$limit);$more=count($rows)>$limit;if($more)array_pop($rows);$result=array();foreach($rows as$row)$result[]=array('id'=>$row->id,'text'=>$row->employee_code.' - '.$row->full_name);return json_response(true,'Daftar karyawan.',array('results'=>$result,'pagination'=>array('more'=>$more)));
}
public function lookup_journals()
{
$q=trim((string)$this->input->get('q',true));$this->db->select("id,CONCAT(no_ref,' - ',DATE_FORMAT(tanggal,'%d/%m/%Y'),' - ',LEFT(keterangan,80)) text",false)->where(array('company_id'=>$this->company(),'status'=>'posted'));if($q!=='')$this->db->group_start()->like('no_ref',$q)->or_like('keterangan',$q)->group_end();$rows=$this->db->order_by('tanggal','DESC')->limit(25)->get('journals')->result();return json_response(true,'Daftar jurnal.',array('results'=>$rows));
}
public function master_data(){return json_response(true,'Master aset.',array('categories'=>$this->fixedasset->categories($this->company()),'locations'=>$this->fixedasset->locations($this->company())));}
public function save_category(){ $this->requirePermission('asset','can_update');return$this->action(fn()=>$this->fixedassetservice->saveCategory(array('id'=>$this->input->post('id'),'code'=>$this->input->post('code',true),'name'=>$this->input->post('name',true),'life'=>$this->input->post('life'),'residual_percent'=>$this->money($this->input->post('residual_percent')),'method'=>$this->input->post('method',true),'asset_account_id'=>$this->input->post('asset_account_id'),'accumulated_account_id'=>$this->input->post('accumulated_account_id'),'expense_account_id'=>$this->input->post('expense_account_id'),'is_active'=>$this->input->post('is_active')),$this->uid()),'Kategori aset berhasil disimpan.');}
public function save_location(){ $this->requirePermission('asset','can_update');return$this->action(fn()=>$this->fixedassetservice->saveLocation(array('id'=>$this->input->post('id'),'code'=>$this->input->post('code',true),'name'=>$this->input->post('name',true),'address'=>$this->input->post('address',true),'responsible_employee_id'=>$this->input->post('responsible_employee_id'),'is_active'=>$this->input->post('is_active')),$this->uid()),'Lokasi aset berhasil disimpan.');}
public function opname_create(){ $this->requirePermission('asset','can_create');return$this->action(fn()=>$this->fixedassetservice->createOpname((int)$this->input->post('location_id'),$this->input->post('date',true),$this->input->post('notes',true),$this->input->post('idempotency_key',true),$this->uid()),'Draft asset opname berhasil dibuat.');}
public function opname_detail($id){$row=$this->fixedasset->opnameDetail((int)$id,$this->company());if(!$row)return json_response(false,'Asset opname tidak ditemukan.',array(),404);foreach($row->attachments as$file)$file->preview_url=base_url('accountingattachments/preview/'.$file->id);return json_response(true,'Detail asset opname.',array('opname'=>$row,'permissions'=>$this->permissions()));}
public function opname_save($id){$this->requirePermission('asset','can_update');return$this->action(function()use($id){$lines=json_decode((string)$this->input->post('lines'),true);if(!is_array($lines))throw new BusinessException('Detail opname tidak valid.');return$this->fixedassetservice->saveOpnameLines($id,$lines,$this->uid());},'Hasil hitung fisik berhasil disimpan.');}
public function opname_state($id){$action=$this->input->post('action',true);$this->requirePermission('asset',in_array($action,array('approve','reject'),true)?'can_approve':($action==='post'?'can_post':'can_create'));return$this->action(fn()=>$this->fixedassetservice->opnameState($id,$action,$this->input->post('reason',true),$this->uid(),$this->master()),'Status asset opname berhasil diperbarui.');}
public function reconciliation()
{
$this->requirePermission('asset','can_view');$date=$this->input->get('date')?:date('Y-m-d');try{$result=$this->fixedasset->reconciliation($this->company(),$date);return json_response(true,'Rekonsiliasi aset selesai tanpa jurnal otomatis.',array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);}
}
public function report_data(){try{$type=$this->input->get('type')?:'register';$filters=$this->reportFilters();if($type==='reconciliation')return json_response(true,'Laporan rekonsiliasi.',array('data'=>array($this->fixedasset->reconciliation($this->company(),$filters['date_to']))));return json_response(true,'Laporan aset.',array('data'=>$this->fixedasset->report($this->company(),$type,$filters)));}catch(Throwable$e){return business_exception_response($e);}}
private function reportFilters(){return array('category_id'=>$this->input->get('category_id'),'lokasi_asset_id'=>$this->input->get('location_id'),'source_type'=>$this->input->get('source_type'),'workflow_status'=>$this->input->get('workflow_status'),'lifecycle_status'=>$this->input->get('lifecycle_status'),'date_from'=>$this->input->get('date_from')?:date('Y-01-01'),'date_to'=>$this->input->get('date_to')?:date('Y-m-d'));}
private function reportDefinition($type){$all=array('register'=>array('title'=>'Asset Register','columns'=>array('kode_asset'=>'Kode','nama_asset'=>'Nama Aset','category_name'=>'Kategori','location_name'=>'Lokasi','source_type'=>'Sumber','tanggal_perolehan'=>'Perolehan','nilai_perolehan'=>'Nilai Perolehan','akumulasi_penyusutan'=>'Akumulasi','nilai_buku'=>'Nilai Buku','lifecycle_status'=>'Status')),'depreciation'=>array('title'=>'Schedule Penyusutan','columns'=>array('period'=>'Periode','kode_asset'=>'Kode','nama_asset'=>'Aset','opening_book_value'=>'Nilai Awal','depreciation_amount'=>'Penyusutan','closing_book_value'=>'Nilai Akhir','status'=>'Status','journal_no'=>'Jurnal')),'maintenance'=>array('title'=>'Riwayat Maintenance','columns'=>array('maintenance_no'=>'Dokumen','maintenance_date'=>'Tanggal','kode_asset'=>'Kode','nama_asset'=>'Aset','vendor'=>'Vendor','description'=>'Keterangan','cost'=>'Biaya','next_due_date'=>'Jadwal Berikutnya','status'=>'Status')),'events'=>array('title'=>'Mutasi dan Audit Aset','columns'=>array('event_no'=>'Dokumen','event_date'=>'Tanggal','event_type'=>'Jenis','kode_asset'=>'Kode','nama_asset'=>'Aset','amount'=>'Nilai','book_value_before'=>'Nilai Sebelum','book_value_after'=>'Nilai Sesudah','journal_no'=>'Jurnal','notes'=>'Keterangan')),'reconciliation'=>array('title'=>'Rekonsiliasi Aset dengan Buku Besar','columns'=>array('as_of'=>'Tanggal','cost_subledger'=>'Perolehan Subledger','cost_gl'=>'Akun Aset GL','cost_difference'=>'Selisih Aset','accumulated_subledger'=>'Akumulasi Subledger','accumulated_gl'=>'Akumulasi GL','accumulated_difference'=>'Selisih Akumulasi','book_value'=>'Nilai Buku','status'=>'Status')));if(!isset($all[$type]))throw new BusinessException('Jenis laporan aset tidak valid.');return$all[$type];}
public function report_pdf(){ $this->requirePermission('asset','can_export');$type=$this->input->get('type')?:'register';$filters=$this->reportFilters();$definition=$this->reportDefinition($type);$rows=$type==='reconciliation'?array((object)$this->fixedasset->reconciliation($this->company(),$filters['date_to'])):$this->fixedasset->report($this->company(),$type,$filters);$this->inlinePdf($this->load->view('fixed_assets/report_pdf',array('definition'=>$definition,'rows'=>$rows,'filters'=>$filters,'company'=>$this->companyRow()),true),'Laporan-Aset-'.$type.'-'.date('Ymd').'.pdf','landscape');}
public function report_excel(){ $this->requirePermission('asset','can_export');$type=$this->input->get('type')?:'register';$filters=$this->reportFilters();$definition=$this->reportDefinition($type);$rows=$type==='reconciliation'?array((object)$this->fixedasset->reconciliation($this->company(),$filters['date_to'])):$this->fixedasset->report($this->company(),$type,$filters);require_once FCPATH.'vendor/autoload.php';$book=new\PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$book->getActiveSheet();$last=\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(count($definition['columns']));$company=$this->companyRow();$sheet->setCellValue('A1',$company->name??'Perusahaan')->mergeCells('A1:'.$last.'1');$sheet->setCellValue('A2',$definition['title'])->mergeCells('A2:'.$last.'2');$sheet->setCellValue('A3','Periode '.$filters['date_from'].' s.d. '.$filters['date_to'])->mergeCells('A3:'.$last.'3');$col=1;foreach($definition['columns']as$label)$sheet->setCellValueByColumnAndRow($col++,5,$label);$rowNo=6;foreach($rows as$row){$col=1;foreach($definition['columns']as$key=>$label)$sheet->setCellValueByColumnAndRow($col++,$rowNo,$row->$key??null);$rowNo++;}$sheet->getStyle('A1:'.$last.'2')->getFont()->setBold(true);$sheet->getStyle('A5:'.$last.'5')->getFont()->setBold(true);for($i=1;$i<=count($definition['columns']);$i++)$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($i))->setAutoSize(true);while(ob_get_level()>0)ob_end_clean();header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="Laporan-Aset-'.$type.'-'.date('Ymd-His').'.xlsx"');\PhpOffice\PhpSpreadsheet\IOFactory::createWriter($book,'Xlsx')->save('php://output');exit;}
public function asset_pdf($id){$this->requirePermission('asset','can_export');$asset=$this->fixedasset->detail((int)$id,$this->company());if(!$asset)show_404();$this->inlinePdf($this->load->view('fixed_assets/asset_pdf',array('asset'=>$asset,'company'=>$this->companyRow(),'qr'=>$this->qrData(base_url('fixedassets/register/'.$asset->kode_asset))),true),'Kartu-Aset-'.$asset->kode_asset.'.pdf');}
public function opname_pdf($id){$this->requirePermission('asset','can_export');$opname=$this->fixedasset->opnameDetail((int)$id,$this->company());if(!$opname)show_404();$this->inlinePdf($this->load->view('fixed_assets/opname_pdf',array('opname'=>$opname,'company'=>$this->companyRow()),true),'Opname-'.$opname->opname_no.'.pdf','landscape');}
public function qr($id){$asset=$this->fixedasset->detail((int)$id,$this->company());if(!$asset)show_404();$this->output->set_content_type('text/html')->set_output($this->load->view('fixed_assets/qr_label',array('asset'=>$asset,'qr'=>$this->qrData(base_url('fixedassets/register/'.$asset->kode_asset))),true));}
public function register($code){$asset=$this->db->select('a.kode_asset,a.nama_asset,a.lifecycle_status,a.nilai_buku,l.nama location_name,e.full_name responsible_name')->from('assets a')->join('lokasi_asset l','l.id=a.lokasi_asset_id','left')->join('k_employees e','e.id=a.responsible_employee_id','left')->where(array('a.kode_asset'=>$code,'a.company_id'=>$this->company()))->get()->row();if(!$asset)return json_response(false,'Aset tidak ditemukan.',array(),404);return json_response(true,'Identitas aset terverifikasi.',array('asset'=>$asset));}
private function companyRow(){return$this->db->get_where('companies',array('id'=>$this->company()))->row();}
private function inlinePdf($html,$filename,$orientation='portrait'){require_once FCPATH.'vendor/autoload.php';$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4',$orientation);$pdf->render();$pdf->stream($filename,array('Attachment'=>false));}
private function qrData($text){require_once APPPATH.'libraries/phpqrcode/qrlib.php';ob_start();QRcode::png($text,null,QR_ECLEVEL_M,5,1);$png=ob_get_clean();return'data:image/png;base64,'.base64_encode($png);}
private function qty($value){$number=(float)$value;return number_format($number,abs($number-round($number))<.0001?0:4,',','.');}
}
+24 -11
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Generatebuku extends CI_Controller
class Generatebuku extends MY_Controller
{
public function __construct()
{
@@ -15,10 +15,17 @@ class Generatebuku extends CI_Controller
public function generatepdf($account_id, $start, $end)
{
if ($this->session->userdata('role') != 'Admin') {
if (!is_master_admin_user() && $this->session->userdata('role') != 'Admin') {
show_error('Akses ditolak', 403);
}
$startDate = DateTime::createFromFormat('Y-m-d', (string) $start);
$endDate = DateTime::createFromFormat('Y-m-d', (string) $end);
if (!$startDate || $startDate->format('Y-m-d') !== $start
|| !$endDate || $endDate->format('Y-m-d') !== $end || $end < $start) {
show_error('Periode Buku Besar tidak valid.', 422);
}
if (ob_get_length()) {
ob_end_clean();
}
@@ -28,8 +35,12 @@ class Generatebuku extends CI_Controller
// =============================================================
$akun = null;
if (!empty($account_id)) {
$akun = $this->db->get_where('accounts', ['id' => (int) $account_id])->row();
$akun = $this->db->get_where('accounts', ['id' => (int) $account_id, 'company_id'=>(int)$this->companycontext->id()])->row();
}
if (!$akun) show_error('Akun Buku Besar tidak ditemukan pada perusahaan aktif.', 404);
$openingRow=$this->db->select('COALESCE(SUM(d.debit),0) debit,COALESCE(SUM(d.kredit),0) kredit',false)->from('journal_details d')->join('journals j','j.id=d.journal_id')->where(array('j.company_id'=>(int)$this->companycontext->id(),'d.account_id'=>(int)$account_id))->where_in('j.status',array('posted','reversed'))->where('j.tanggal <',$start)->get()->row();
$saldoAwal=in_array($akun->tipe,array('asset','expense'),true)?(float)$openingRow->debit-(float)$openingRow->kredit:(float)$openingRow->kredit-(float)$openingRow->debit;
// =============================================================
// QUERY DATA
@@ -45,6 +56,8 @@ class Generatebuku extends CI_Controller
$this->db->from('journal_details');
$this->db->join('journals', 'journals.id = journal_details.journal_id');
$this->db->join('accounts', 'accounts.id = journal_details.account_id');
$this->db->where_in('journals.status', array('posted', 'reversed'));
$this->db->where('journals.company_id', (int)$this->companycontext->id());
if (!empty($account_id)) {
$this->db->where('journal_details.account_id', (int) $account_id);
@@ -60,14 +73,14 @@ class Generatebuku extends CI_Controller
$this->db->or_where('journal_details.kredit >', 0);
$this->db->group_end();
$this->db->order_by('journals.tanggal', 'ASC');
$this->db->order_by('journals.tanggal', 'ASC')->order_by('journals.id','ASC')->order_by('journal_details.id','ASC');
$rows = $this->db->get()->result();
// =============================================================
// HITUNG SALDO BERJALAN
// =============================================================
$saldo = 0;
$saldo = $saldoAwal;
$result = [];
foreach ($rows as $r) {
@@ -93,7 +106,7 @@ class Generatebuku extends CI_Controller
// =============================================================
// DATA PERUSAHAAN
// =============================================================
$company = $this->db->get('pengaturan')->row();
$company = $this->db->get_where('companies',array('id'=>(int)$this->companycontext->id()))->row();
// =============================================================
// BUILD STRING INFO LAPORAN
@@ -116,16 +129,16 @@ class Generatebuku extends CI_Controller
$pdf->SetMargins(10, 10, 10);
$pdf->setHeaderData(
$company->nama_perusahaan,
$company->alamat_perusahaan,
FCPATH . 'uploads/img/logo-ljn.png'
$company->name,
$company->address,
!empty($company->logo_path) ? FCPATH.$company->logo_path : ''
);
$pdf->setInfoLaporan($namaAkun, $periodeStr);
$pdf->AddPage('P', 'A4');
$pdf->BukuBesarTable($result);
$pdf->BukuBesarTotal($total_debit, $total_kredit, $saldo);
$pdf->BukuBesarTotal($saldoAwal, $total_debit, $total_kredit, $saldo);
$pdf->Pembuat('Tito Ngudiana', 'Finance Department');
// =============================================================
@@ -135,4 +148,4 @@ class Generatebuku extends CI_Controller
$pdf->Output('D', $filename);
exit;
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Generateinvoice extends CI_Controller {
class Generateinvoice extends MY_Controller {
public function generate($invoice_id)
{
@@ -190,4 +190,4 @@ class Generateinvoice extends CI_Controller {
return "";
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Generateitems extends CI_Controller {
class Generateitems extends MY_Controller {
public function __construct()
@@ -65,4 +65,4 @@ $pdf->TotalBarang(count($data));
// $pdf->Pembuat($company['nama_bendahara']);
$pdf->Output('I','Data_Barang_'.date('YmdHis').'.pdf');
}
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Generatelabarugi extends CI_Controller {
class Generatelabarugi extends MY_Admin_Controller {
public function __construct()
{
@@ -43,6 +43,7 @@ class Generatelabarugi extends CI_Controller {
// ================= JOIN
$this->db->join('journal_details jd','a.id = jd.account_id','LEFT');
$this->db->join('journals j','j.id = jd.journal_id','LEFT');
$this->db->where("(j.status IN ('posted','reversed') OR jd.id IS NULL)", null, false);
// ================= FILTER AKUN
$this->db->where('a.kategori','laba_rugi');
@@ -194,4 +195,4 @@ class Generatelabarugi extends CI_Controller {
$pdf->Output('D', $filename);
exit;
}
}
}
+5 -3
View File
@@ -2,7 +2,7 @@
defined('BASEPATH') OR exit('No direct script access allowed');
class Generateneraca extends CI_Controller {
class Generateneraca extends MY_Admin_Controller {
public function __construct()
{
@@ -39,6 +39,7 @@ class Generateneraca extends CI_Controller {
$this->db->from('accounts');
$this->db->join('journal_details jd','jd.account_id = accounts.id','LEFT');
$this->db->join('journals j','j.id = jd.journal_id','LEFT');
$this->db->where("(j.status IN ('posted','reversed') OR jd.id IS NULL)", null, false);
$this->db->where_in('accounts.tipe',['revenue','expense']);
@@ -85,6 +86,7 @@ class Generateneraca extends CI_Controller {
$this->db->from('accounts a');
$this->db->join('journal_details jd','jd.account_id = a.id','LEFT');
$this->db->join('journals j','j.id = jd.journal_id','LEFT');
$this->db->where("(j.status IN ('posted','reversed') OR jd.id IS NULL)", null, false);
$this->db->where('a.kategori','neraca');
@@ -126,7 +128,7 @@ class Generateneraca extends CI_Controller {
}
// ================= LABA BERJALAN
if ($r->kode_akun == '302') {
if ($r->kode_akun == '3020') {
$saldo = $laba;
}
@@ -249,4 +251,4 @@ class Generateneraca extends CI_Controller {
$pdf->Output('D', $filename);
exit;
}
}
}
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Generateneracasaldo extends CI_Controller {
class Generateneracasaldo extends MY_Admin_Controller {
public function __construct()
{
@@ -56,7 +56,8 @@ class Generateneracasaldo extends CI_Controller {
"(SELECT jd.*
FROM journal_details jd
JOIN journals j ON j.id = jd.journal_id
WHERE j.tanggal BETWEEN ".$this->db->escape($tanggal_awal)."
WHERE j.status IN ('posted','reversed')
AND j.tanggal BETWEEN ".$this->db->escape($tanggal_awal)."
AND ".$this->db->escape($tanggal_akhir)."
) journal_details",
"journal_details.account_id = accounts.id",
@@ -185,4 +186,4 @@ class Generateneracasaldo extends CI_Controller {
$pdf->Output('I',$filename);
exit;
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Generateschedule extends CI_Controller {
class Generateschedule extends MY_Controller {
public function __construct()
{
@@ -765,4 +765,4 @@ public function delete_schedule()
}
}
}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Holidays extends CI_Controller {
class Holidays extends MY_Controller {
public function __construct()
{
@@ -174,4 +174,4 @@ class Holidays extends CI_Controller {
]);
}
}
}
+156
View File
@@ -0,0 +1,156 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH.'exceptions/BusinessException.php';
class Hrpayroll extends MY_Controller
{
public function __construct()
{
parent::__construct();$this->load->database();$this->load->library(array('PayrollService','AuditService','FormulaService'));
if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php';
}
private function uid(){return(int)$this->session->userdata('user_id');}
private function company(){return(int)$this->session->userdata('company_id');}
private function allSalary(){return is_master_admin_user()||check_permission('payroll','can_manage_all_salary');}
private function canManageAllowances(){return is_master_admin_user()||check_permission('payroll','can_manage_allowances');}
private function can($action){return is_master_admin_user()||check_permission('payroll',$action);}
private function go($ok,$message,$section='periods'){$this->session->set_flashdata($ok?'success':'error',$message);redirect('hrpayroll?section='.$section);}
private function validDate($value){$date=DateTime::createFromFormat('Y-m-d',(string)$value);return$date&&$date->format('Y-m-d')===$value?$value:null;}
private function jsonError(Throwable$e){return json_response(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);}
private function requireAction($action,$message){if(!$this->can($action))throw new BusinessException($message);}
private function requirePost(){if(strtoupper((string)$this->input->method())!=='POST')throw new BusinessException('Metode request tidak diizinkan.');}
private function isAdditionalComponent($component){if(!$component)return false;$code=strtoupper((string)$component->code);if($code==='BASIC')return$component->component_type!=='earning';return$component->component_type==='employer_cost'||$component->component_type==='earning';}
private function canManageComponent($component){return!$this->isAdditionalComponent($component)||$this->canManageAllowances();}
private function assertManageComponent($component){if(!$this->canManageComponent($component))throw new BusinessException('Tunjangan, bonus, lembur, THR, dan pendapatan tambahan hanya dapat dikelola dengan permission Kelola Tunjangan & Bonus.');}
private function invalidateDraftCalculations()
{
$this->db->where(array('company_id'=>$this->company(),'workflow_status'=>'draft'))->update('k_payroll_periods',array('calculated_at'=>null,'calculated_by'=>null,'calculation_hash'=>null,'updated_at'=>date('Y-m-d H:i:s')));
}
public function index()
{
if(!$this->can('can_view'))show_error('Anda tidak memiliki izin melihat payroll.',403,'Akses ditolak');
$section=$this->input->get('section',true)?:'periods';if(!in_array($section,array('periods','salaries','settings','adjustments'),true))$section='periods';
$company=$this->company();$visible=$this->allSalary()?'1=1':'COALESCE(e.salary_restricted,0)=0';
$periods=$this->db->query("SELECT p.*,COUNT(CASE WHEN {$visible} THEN x.id END) employee_count,COALESCE(SUM(CASE WHEN {$visible} THEN x.gross_salary ELSE 0 END),0) gross,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_deductions ELSE 0 END),0) deductions,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_salary ELSE 0 END),0) net,l.status attendance_lock_status FROM k_payroll_periods p LEFT JOIN k_payrolls x ON x.payroll_period_id=p.id LEFT JOIN k_employees e ON e.id=x.employee_id LEFT JOIN payroll_attendance_locks l ON l.payroll_period_id=p.id AND l.company_id=p.company_id WHERE p.company_id=? GROUP BY p.id,l.status ORDER BY COALESCE(p.attendance_start_date,p.start_date) DESC",array($company))->result();
$components=$this->db->where('company_id',$company)->order_by('component_type')->order_by('code')->get('payroll_components')->result();
$employees=$this->db->select('e.id,e.employee_code,e.full_name,e.department_id,e.position_id,e.is_active,e.salary_restricted,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where('e.company_id',$company)->order_by('e.full_name')->get()->result();
if(!$this->allSalary())$employees=array_values(array_filter($employees,function($e){return empty($e->salary_restricted);}));
$salaryRows=$this->salarySummaries($employees,$components);
$positionQuery=$this->db->select('p.id,p.code,p.position_name,p.department_id,d.department_name')->from('k_positions p')->join('k_departments d','d.id=p.department_id','left')->where('p.is_active',1);if($this->db->field_exists('company_id','k_positions'))$positionQuery->where('p.company_id',$company);$positions=$positionQuery->order_by('p.position_name')->get()->result();
$departmentQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','k_departments'))$departmentQuery->where('company_id',$company);$departments=$departmentQuery->order_by('department_name')->get('k_departments')->result();
$positionAssignments=$this->db->select('x.*,p.position_name,c.code component_code,c.name component_name,c.component_type')->from('position_payroll_components x')->join('k_positions p','p.id=x.position_id')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where('x.company_id',$company)->order_by('p.position_name')->order_by('c.name')->get()->result();
$rules=$this->db->select('r.*,c.code component_code,c.name component_name')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id AND c.company_id=r.company_id','left')->where('r.company_id',$company)->order_by('r.condition_type')->order_by('r.effective_from','DESC')->get()->result();
$adjustments=$this->db->select('a.*,p.period_name,p.workflow_status,e.employee_code,e.full_name,c.code component_code,c.name component_name,c.component_type')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id AND p.company_id=a.company_id')->join('k_employees e','e.id=a.employee_id AND e.company_id=a.company_id')->join('payroll_components c','c.id=a.component_id AND c.company_id=a.company_id')->where('a.company_id',$company)->where('a.is_active',1)->order_by('a.id','DESC')->get()->result();
if(!$this->allSalary())$adjustments=array_values(array_filter($adjustments,function($row)use($employees){foreach($employees as$employee)if($employee->id==$row->employee_id)return true;return false;}));
foreach($adjustments as$row)$row->can_manage=$this->canManageComponent($row);
$cashQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$company);$cash=$cashQuery->order_by('name')->get('cash_accounts')->result();
$accounts=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('kode_akun')->get('accounts')->result();
$settings=$this->db->get_where('payroll_settings',array('company_id'=>$company))->row();
$manageableComponents=array_values(array_filter($components,function($component){return$this->canManageComponent($component);}));
$adjustmentComponents=array_values(array_filter($manageableComponents,function($component){return$component->component_type==='deduction'||($this->canManageAllowances()&&$component->component_type==='earning'&&strtoupper((string)$component->code)!=='BASIC');}));
$data=array('active_menu'=>'hr_payroll','payroll_section'=>$section,'periods'=>$periods,'components'=>$components,'manageable_components'=>$manageableComponents,'adjustment_components'=>$adjustmentComponents,'employees'=>$employees,'salary_rows'=>$salaryRows,'positions'=>$positions,'departments'=>$departments,'position_assignments'=>$positionAssignments,'attendance_rules'=>$rules,'adjustments'=>$adjustments,'cash_accounts'=>$cash,'accounts'=>$accounts,'payroll_settings'=>$settings,'can_manage_all_salary'=>$this->allSalary(),'can_manage_allowances'=>$this->canManageAllowances(),'permissions'=>array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export')));
$this->load->view('partials/header',$data);$this->load->view('hr_payroll/index',$data);$this->load->view('partials/footer');
}
private function salarySummaries($employees,$components)
{
$componentMap=array();foreach($components as$c)$componentMap[$c->id]=$c;$today=date('Y-m-d');$rows=array();
foreach($employees as$employee){$direct=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->result();$position=$this->db->where(array('company_id'=>$this->company(),'position_id'=>$employee->position_id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('position_payroll_components')->result();$resolved=array();foreach($position as$x)if(!isset($resolved[$x->component_id]))$resolved[$x->component_id]=$x;foreach($direct as$x)$resolved[$x->component_id]=$x;$earning=0;$deduction=0;$effective=null;foreach($resolved as$x){if(!isset($componentMap[$x->component_id]))continue;$value=(float)($x->amount_override!==null?$x->amount_override:($componentMap[$x->component_id]->default_value!==null?$componentMap[$x->component_id]->default_value:$componentMap[$x->component_id]->fixed_amount));if($x->calculation_method==='fixed'){if($componentMap[$x->component_id]->component_type==='earning')$earning+=$value;elseif($componentMap[$x->component_id]->component_type==='deduction')$deduction+=$value;}if(!$effective||$x->effective_from>$effective)$effective=$x->effective_from;}$employee->fixed_earnings=$earning;$employee->fixed_deductions=$deduction;$employee->estimated_net=$earning-$deduction;$employee->effective_from=$effective;$rows[]=$employee;}return$rows;
}
public function create_period()
{
try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission membuat periode payroll.');$name=trim((string)$this->input->post('period_name',true));$start=$this->validDate($this->input->post('attendance_start_date'));$end=$this->validDate($this->input->post('attendance_end_date'));$payment=$this->validDate($this->input->post('payment_date'));if($name===''||!$start||!$end||!$payment)throw new BusinessException('Nama periode, rentang kehadiran, dan tanggal pembayaran wajib diisi.');if($end<$start)throw new BusinessException('Tanggal akhir kehadiran tidak boleh sebelum tanggal awal.');$overlap=(int)$this->db->query("SELECT COUNT(*) total FROM k_payroll_periods WHERE company_id=? AND workflow_status<>'cancelled' AND COALESCE(attendance_start_date,start_date)<=? AND COALESCE(attendance_end_date,end_date)>=?",array($this->company(),$end,$start))->row()->total;if($overlap)throw new BusinessException('Rentang kehadiran tumpang tindih dengan periode payroll lain.');$row=array('company_id'=>$this->company(),'period_name'=>$name,'start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>$payment,'notes'=>trim((string)$this->input->post('notes',true))?:null,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s'));$this->db->insert('k_payroll_periods',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','payroll_period',$id,'create',null,$row,$this->uid());$this->go(true,'Periode payroll berhasil dibuat.');}catch(Throwable$e){$this->go(false,$e->getMessage());}
}
public function lock_attendance()
{
try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission mengunci kehadiran payroll.');$id=(int)$this->input->post('period_id');$period=$this->period($id);if($period->workflow_status!=='draft')throw new BusinessException('Kehadiran hanya dapat dikunci ketika payroll masih Draft.');$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;$pending=array();if($this->db->table_exists('manual_attendance_requests')){$n=$this->db->where('company_id',$this->company())->where('status','submitted')->where('attendance_date >=',$start)->where('attendance_date <=',$end)->count_all_results('manual_attendance_requests');if($n)$pending[]=$n.' absensi manual';}if($this->db->table_exists('k_leave_requests')){$n=$this->db->where('company_id',$this->company())->where('workflow_status','submitted')->where('start_date <=',$end)->where('end_date >=',$start)->count_all_results('k_leave_requests');if($n)$pending[]=$n.' cuti/izin';}if($this->db->table_exists('attendance_corrections')){$n=$this->db->from('attendance_corrections c')->join('k_attendances a','a.id=c.attendance_id')->join('k_employees e','e.id=a.employee_id')->where(array('e.company_id'=>$this->company(),'c.status'=>'submitted'))->where('a.attendance_date >=',$start)->where('a.attendance_date <=',$end)->count_all_results();if($n)$pending[]=$n.' koreksi absensi';}if($pending)throw new BusinessException('Periode belum dapat dikunci karena masih ada '.implode(', ',$pending).' menunggu approval.');$existing=$this->db->get_where('payroll_attendance_locks',array('company_id'=>$this->company(),'payroll_period_id'=>$id))->row();$row=array('company_id'=>$this->company(),'payroll_period_id'=>$id,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>trim((string)$this->input->post('notes',true))?:null);$this->db->trans_begin();if($existing)$this->db->where('id',$existing->id)->update('payroll_attendance_locks',$row);else$this->db->insert('payroll_attendance_locks',$row);$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_payroll_periods',array('attendance_locked_by'=>$this->uid(),'attendance_locked_at'=>date('Y-m-d H:i:s'),'calculated_at'=>null,'calculated_by'=>null,'calculation_hash'=>null,'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('payroll','attendance_lock',$id,'lock',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Kunci kehadiran gagal disimpan.');$this->db->trans_commit();$this->go(true,'Kehadiran '.$start.' sampai '.$end.' berhasil dikunci.');}catch(Throwable$e){if($this->db->trans_status()!==null)$this->db->trans_rollback();$this->go(false,$e->getMessage());}
}
public function calculate(){try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission menghitung payroll.');$id=$this->payrollservice->calculate((int)$this->input->post('period_id'),$this->uid(),$this->allSalary(),$this->company());$this->go(true,'Draft payroll periode #'.$id.' berhasil dihitung.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function workflow($id,$action){try{$this->requirePost();$this->requireAction('can_approve','Anda tidak mempunyai permission approval payroll.');$this->payrollservice->workflow($id,$action,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Tahap payroll berhasil diperbarui.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function post($id){try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission posting payroll.');$date=$this->validDate($this->input->post('date'));if(!$date)throw new BusinessException('Tanggal posting tidak valid.');$this->payrollservice->post($id,$date,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function pay($id){try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission membayar payroll.');$date=$this->validDate($this->input->post('date'));if(!$date)throw new BusinessException('Tanggal pembayaran tidak valid.');$this->payrollservice->pay($id,$date,(int)$this->input->post('cash_account_id'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil dibayar.');}catch(Throwable$e){$this->go(false,$e->getMessage());}}
public function period_detail($id)
{
try{$this->requireAction('can_view','Anda tidak mempunyai permission melihat payroll.');$period=$this->period($id,false);if(!$period)return json_response(false,'Periode payroll tidak ditemukan.',array(),404);$q=$this->db->select('p.id,p.basic_salary,p.gross_salary,p.total_deductions,p.total_salary,p.total_present,p.total_alpha,p.total_overtime_hours,e.employee_code,e.full_name,e.salary_restricted')->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id AND e.company_id=p.company_id')->where(array('p.payroll_period_id'=>$period->id,'p.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);return json_response(true,'Rincian payroll.',array('period'=>$period,'rows'=>$q->order_by('e.full_name')->get()->result()));}catch(Throwable$e){return$this->jsonError($e);}
}
public function salary_detail($employeeId)
{
try{$this->requireAction('can_view','Anda tidak mempunyai permission melihat payroll.');$employee=$this->employee((int)$employeeId);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$lines=$this->db->select('x.*,c.code component_code,c.name component_name,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.company_id'=>$this->company(),'x.employee_id'=>$employee->id))->order_by('x.effective_from','DESC')->get()->result();foreach($lines as$line)$line->can_manage=$this->can('can_update')&&$this->canManageComponent((object)array('component_type'=>$line->component_type,'code'=>$line->component_code));return json_response(true,'Struktur gaji.',array('employee'=>$employee,'lines'=>$lines));}catch(Throwable$e){return$this->jsonError($e);}
}
public function save_salary_line()
{
try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah struktur gaji.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$row=$this->assignmentInput('employee',$employee->id);$duplicate=$this->db->get_where('employee_payroll_components',array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row();if($duplicate)throw new BusinessException('Komponen dengan tanggal mulai yang sama sudah ada. Gunakan tanggal efektif baru agar histori tidak tertimpa.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();if($previous)$this->db->where('id',$previous->id)->update('employee_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('employee_payroll_components',$row);$id=(int)$this->db->insert_id();if($row['component_id']===$this->basicComponentId())$this->db->where(array('id'=>$employee->id,'company_id'=>$this->company()))->update('k_employees',array('basic_salary'=>$row['amount_override']));$this->invalidateDraftCalculations();$this->auditservice->record('payroll','employee_salary_component',$id,'create_version',$previous,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Struktur gaji gagal disimpan.');$this->db->trans_commit();return json_response(true,'Versi struktur gaji berhasil disimpan. Draft payroll wajib dihitung ulang.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);}
}
public function deactivate_salary_line($id)
{
try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah struktur gaji.');$line=$this->db->select('x.*,c.code component_code,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.id'=>(int)$id,'x.company_id'=>$this->company()))->get()->row();if(!$line)throw new BusinessException('Komponen gaji tidak ditemukan.');$this->assertManageComponent((object)array('component_type'=>$line->component_type,'code'=>$line->component_code));$employee=$this->employee($line->employee_id);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');if(!(int)$line->is_active)throw new BusinessException('Komponen gaji sudah nonaktif.');$update=array('is_active'=>0,'effective_to'=>date('Y-m-d'),'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->where('id',$line->id)->update('employee_payroll_components',$update);$this->invalidateDraftCalculations();$this->auditservice->record('payroll','employee_salary_component',$line->id,'deactivate',$line,$update,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Komponen gaji gagal dinonaktifkan.');$this->db->trans_commit();return json_response(true,'Komponen dinonaktifkan tanpa menghapus histori. Draft payroll wajib dihitung ulang.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);}
}
public function component()
{
try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengelola komponen.');$id=(int)$this->input->post('id');$before=$id?$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row():null;if($id&&!$before)throw new BusinessException('Komponen tidak ditemukan.');$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$type=$this->input->post('component_type');$method=$this->method($this->input->post('calculation_method'));$value=$this->decimal($this->input->post('default_value'));if($code===''||$name===''||!in_array($type,array('earning','deduction','employer_cost'),true))throw new BusinessException('Kode, nama, dan jenis komponen wajib diisi.');$proposed=(object)array('code'=>$code,'component_type'=>$type);if(!$this->canManageAllowances()){if(!$before&&$type!=='deduction')throw new BusinessException('Tanpa permission Kelola Tunjangan & Bonus, komponen baru hanya boleh berupa potongan.');if($before){$this->assertManageComponent($before);$this->assertManageComponent($proposed);if(strtoupper((string)$before->code)==='BASIC'&&$code!=='BASIC')throw new BusinessException('Kode komponen Gaji Pokok tidak boleh diubah.');}}$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'component_type'=>$type,'category'=>$this->input->post('category')?:'other','calculation_method'=>$method,'default_value'=>$value,'fixed_amount'=>$value,'formula_expression'=>$formula,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>$this->input->post('is_active')?1:0,'effective_from'=>$this->validDate($this->input->post('effective_from'))?:date('Y-m-d'),'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'updated_at'=>date('Y-m-d H:i:s'));$duplicate=$this->db->where(array('company_id'=>$this->company(),'code'=>$code))->where('id !=',$id)->count_all_results('payroll_components');if($duplicate)throw new BusinessException('Kode komponen sudah digunakan.');$this->db->trans_begin();if($id)$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('payroll_components',$row);else{$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_components',$row);$id=(int)$this->db->insert_id();}$this->invalidateDraftCalculations();$this->auditservice->record('payroll','payroll_component',$id,$before?'update':'create',$before,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Komponen payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Komponen payroll berhasil disimpan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');}
}
public function save_position_line()
{
try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah template posisi.');$positionId=(int)$this->input->post('position_id');$positionQuery=$this->db->where('id',$positionId);if($this->db->field_exists('company_id','k_positions'))$positionQuery->where('company_id',$this->company());if(!$positionId||!$positionQuery->count_all_results('k_positions'))throw new BusinessException('Posisi tidak ditemukan pada perusahaan aktif.');$row=$this->assignmentInput('position',$positionId);if($this->db->get_where('position_payroll_components',array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row())throw new BusinessException('Template komponen dengan tanggal efektif yang sama sudah ada.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('position_payroll_components')->row();if($previous)$this->db->where(array('id'=>$previous->id,'company_id'=>$this->company()))->update('position_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('position_payroll_components',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','position_salary_component',$id,'create_version',$previous,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Template posisi gagal disimpan.');$this->db->trans_commit();$this->go(true,'Versi template posisi berhasil ditambahkan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');}
}
public function save_attendance_rule()
{
try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah aturan kehadiran payroll.');$condition=$this->input->post('condition_type');$method=$this->attendanceMethod($this->input->post('calculation_method'));$start=$this->validDate($this->input->post('effective_from'));if(!in_array($condition,array('alpha','late','early_leave','unpaid','missing_checkin','missing_checkout'),true)||!$start)throw new BusinessException('Kondisi dan tanggal efektif aturan tidak valid.');$componentId=$this->input->post('component_id')?(int)$this->input->post('component_id'):null;if($method!=='none'&&!$componentId)throw new BusinessException('Komponen potongan wajib dipilih bila aturan menghasilkan potongan.');if($componentId){$component=$this->componentRow($componentId);if($component->component_type!=='deduction')throw new BusinessException('Aturan kehadiran hanya boleh menggunakan komponen potongan.');}if($method==='none')$componentId=null;$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'component_id'=>$componentId,'rule_name'=>trim((string)$this->input->post('rule_name',true)),'condition_type'=>$condition,'calculation_method'=>$method,'tolerance_minutes'=>max(0,(int)$this->input->post('tolerance_minutes')),'rate_value'=>$this->decimal($this->input->post('rate_value')),'maximum_amount'=>$this->input->post('maximum_amount')!==''?$this->decimal($this->input->post('maximum_amount')):null,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'is_active'=>$this->input->post('is_active')?1:0,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));if($row['rule_name']==='')throw new BusinessException('Nama aturan wajib diisi.');$this->db->trans_begin();$this->db->insert('payroll_attendance_rules',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','attendance_rule',$id,'create_version',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Aturan kehadiran gagal disimpan.');$this->db->trans_commit();$this->go(true,'Versi aturan kehadiran berhasil disimpan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');}
}
public function save_adjustment()
{
try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission membuat penyesuaian payroll.');$period=$this->period((int)$this->input->post('payroll_period_id'),false);if(!$period)throw new BusinessException('Periode payroll tidak ditemukan.');if($period->workflow_status!=='draft')throw new BusinessException('Penyesuaian hanya dapat dibuat pada payroll Draft.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$component=$this->componentRow((int)$this->input->post('component_id'));$type=$this->input->post('adjustment_type');if(!in_array($type,array('earning','deduction'),true))throw new BusinessException('Jenis penyesuaian tidak valid.');if($component->component_type!==$type)throw new BusinessException('Jenis penyesuaian harus sama dengan jenis komponen yang dipilih.');if($type==='earning'&&!$this->canManageAllowances())throw new BusinessException('Bonus dan pendapatan tambahan memerlukan permission Kelola Tunjangan & Bonus.');$amount=$this->decimal($this->input->post('amount'));$notes=trim((string)$this->input->post('notes',true));if($amount<=0||$notes==='')throw new BusinessException('Nominal harus lebih dari nol dan keterangan wajib diisi.');$row=array('company_id'=>$this->company(),'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'component_id'=>$component->id,'adjustment_type'=>$type,'amount'=>$amount,'notes'=>$notes,'is_active'=>1,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->insert('payroll_period_adjustments',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','period_adjustment',$id,'create',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Penyesuaian payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Penyesuaian periode berhasil ditambahkan. Draft payroll wajib dihitung ulang.','adjustments');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'adjustments');}
}
public function deactivate_adjustment($id)
{
try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah penyesuaian payroll.');$row=$this->db->select('a.*,p.workflow_status,e.salary_restricted,c.code component_code,c.component_type')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id AND p.company_id=a.company_id')->join('k_employees e','e.id=a.employee_id AND e.company_id=a.company_id')->join('payroll_components c','c.id=a.component_id AND c.company_id=a.company_id')->where(array('a.id'=>(int)$id,'a.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Penyesuaian tidak ditemukan.');if($row->workflow_status!=='draft')throw new BusinessException('Penyesuaian payroll yang bukan Draft tidak dapat diubah.');if($row->salary_restricted&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$this->assertManageComponent((object)array('component_type'=>$row->component_type,'code'=>$row->component_code));$update=array('is_active'=>0,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->where(array('id'=>$row->id,'company_id'=>$this->company()))->update('payroll_period_adjustments',$update);$this->invalidateDraftCalculations();$this->auditservice->record('payroll','period_adjustment',$row->id,'deactivate',$row,$update,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Penyesuaian payroll gagal dinonaktifkan.');$this->db->trans_commit();$this->go(true,'Penyesuaian dinonaktifkan tanpa menghapus histori. Draft payroll wajib dihitung ulang.','adjustments');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'adjustments');}
}
public function save_settings()
{
try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah pengaturan payroll.');if(!$this->canManageAllowances())throw new BusinessException('Pengaturan umum payroll hanya dapat diubah dengan permission Kelola Tunjangan & Bonus.');$day=$this->input->post('default_payment_day');$day=$day===''?null:(int)$day;if($day!==null&&($day<1||$day>31))throw new BusinessException('Tanggal pembayaran default harus 1 sampai 31.');$before=$this->db->get_where('payroll_settings',array('company_id'=>$this->company()))->row();$row=array('separation_of_duties'=>$this->input->post('separation_of_duties')?1:0,'default_payment_day'=>$day,'bank_export_note'=>trim((string)$this->input->post('bank_export_note',true))?:null,'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();if($before)$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('payroll_settings',$row);else{$row['company_id']=$this->company();$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_settings',$row);}$this->auditservice->record('payroll','payroll_settings',$this->company(),'update',$before,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Pengaturan payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Pengaturan umum payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');}
}
private function assignmentInput($subject,$subjectId)
{
$component=$this->componentRow((int)$this->input->post('component_id'));$this->assertManageComponent($component);if(!(int)$component->is_active)throw new BusinessException('Komponen payroll yang dipilih sudah nonaktif.');$method=$this->method($this->input->post('calculation_method'));$amount=$this->decimal($this->input->post('amount_override'));$start=$this->validDate($this->input->post('effective_from'));$end=$this->validDate($this->input->post('effective_to'));if(!$start)throw new BusinessException('Tanggal mulai berlaku wajib diisi.');if($end&&$end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),$subject.'_id'=>$subjectId,'component_id'=>$component->id,'calculation_method'=>$method,'amount_override'=>$amount,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$end?:null,'is_active'=>1,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));return$row;
}
private function method($method){$allowed=array('fixed','per_present_day','per_absent_day','per_minute','per_hour','percentage_basic','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode perhitungan tidak valid.');return$method;}
private function attendanceMethod($method){$allowed=array('none','fixed','per_minute','per_hour','per_day','percentage_daily','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode potongan tidak valid.');return$method;}
private function validateFormula($formula){if(!$formula)throw new BusinessException('Formula wajib diisi.');$variables=array('basic_salary'=>1,'calendar_days'=>1,'work_days'=>1,'present_days'=>1,'alpha_days'=>1,'unpaid_days'=>1,'unpaid_days_total'=>1,'late_minutes'=>1,'early_leave_minutes'=>1,'missing_checkin'=>1,'missing_checkout'=>1,'overtime_hours'=>1,'overtime_rate'=>1,'daily_rate'=>1,'condition_quantity'=>1,'rule_rate'=>1);$this->formulaservice->calculate($formula,$variables);}
private function decimal($value){$number=(float)str_replace(array('. ',','),array('','.'),(string)$value);if($number<0)throw new BusinessException('Nominal atau tarif tidak boleh negatif.');return round($number,2);}
private function employee($id){$row=$this->db->select('e.*,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where(array('e.id'=>$id,'e.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Karyawan tidak ditemukan pada perusahaan aktif.');return$row;}
private function componentRow($id){$row=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Komponen payroll tidak ditemukan.');if(!(int)$row->is_active)throw new BusinessException('Komponen payroll yang dipilih sudah nonaktif.');return$row;}
private function basicComponentId(){$row=$this->db->get_where('payroll_components',array('company_id'=>$this->company(),'code'=>'BASIC'))->row();return$row?(int)$row->id:0;}
private function period($id,$throw=true){$row=$this->db->get_where('k_payroll_periods',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$row&&$throw)throw new BusinessException('Periode payroll tidak ditemukan.');return$row;}
public function slip($payrollId)
{
if(!$this->can('can_view'))show_error('Anda tidak memiliki izin melihat slip payroll.',403,'Akses ditolak');$payroll=$this->db->select('x.*,e.full_name,e.employee_code,e.salary_restricted,r.period_name,r.workflow_status,r.attendance_start_date,r.attendance_end_date,r.payment_date')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->join('k_payroll_periods r','r.id=x.payroll_period_id AND r.company_id=x.company_id')->where(array('x.id'=>(int)$payrollId,'x.company_id'=>$this->company()))->get()->row();if(!$payroll||!in_array($payroll->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);if($payroll->salary_restricted&&!$this->allSalary())show_error('Gaji ini bersifat terbatas.',403);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$payroll,'lines'=>$lines));
}
public function export_pdf($id)
{
try{$this->requireAction('can_export','Anda tidak mempunyai permission export payroll.');$period=$this->exportablePeriod($id);$rows=$this->payrollRows($id);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$dompdf=new Dompdf\Dompdf();$dompdf->loadHtml($html);$dompdf->setPaper('A4','landscape');$dompdf->render();$dompdf->stream('payroll-'.$period->id.'.pdf',array('Attachment'=>true));}catch(Throwable$e){show_error($e->getMessage(),$e instanceof BusinessException?422:500);}
}
public function export_excel($id,$bank='general')
{
try{$this->requireAction('can_export','Anda tidak mempunyai permission export payroll.');$period=$this->exportablePeriod($id);$bank=strtolower($bank);if(!in_array($bank,array('general','bri','bca'),true))throw new BusinessException('Format bank tidak tersedia.');$rows=$this->payrollRows($id,$bank==='general'?null:$bank);if(!$rows)throw new BusinessException('Tidak ada data payroll untuk format yang dipilih.');$spreadsheet=new PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$spreadsheet->getActiveSheet();$sheet->setTitle($bank==='general'?'Payroll Register':strtoupper($bank).' Source');$headers=$bank==='general'?array('No','Kode Karyawan','Nama Karyawan','Bank','Nomor Rekening','Gaji Pokok','Bruto','Potongan','Neto','Tanggal Efektif'):array('No','Nomor Rekening','Nama Penerima','Nominal Neto','Berita Pembayaran','Kode Karyawan','Tanggal Efektif');$sheet->fromArray($headers,null,'A1');$line=2;foreach($rows as$i=>$row){$values=$bank==='general'?array($i+1,$row->employee_code,$row->full_name,$row->bank_name,$row->bank_account_number,(float)$row->basic_salary,(float)$row->gross_salary,(float)$row->total_deductions,(float)$row->total_salary,$period->payment_date):array($i+1,$row->bank_account_number,$row->bank_account_name?:$row->full_name,(float)$row->total_salary,'Gaji '.$period->period_name,$row->employee_code,$period->payment_date);$sheet->fromArray($values,null,'A'.$line++);$accountColumn=$bank==='general'?'E':'B';$sheet->setCellValueExplicit($accountColumn.($line-1),(string)$row->bank_account_number,PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);}$sheet->getStyle('A1:'.$sheet->getHighestColumn().'1')->getFont()->setBold(true);foreach(range('A',$sheet->getHighestColumn())as$column)$sheet->getColumnDimension($column)->setAutoSize(true);if($bank!=='general'){$guide=$spreadsheet->createSheet();$guide->setTitle('PETUNJUK');$guide->fromArray(array(array('File sumber '.strtoupper($bank)),array('Gunakan converter/checksum resmi portal bank perusahaan bila diwajibkan.'),array('Periksa nama, rekening, nominal, dan tanggal efektif sebelum otorisasi.')),null,'A1');$guide->getColumnDimension('A')->setWidth(100);}header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="payroll-'.$bank.'-'.$period->id.'.xlsx"');header('Cache-Control: max-age=0');(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');exit;}catch(Throwable$e){show_error($e->getMessage(),$e instanceof BusinessException?422:500);}
}
private function exportablePeriod($id){$period=$this->period($id);if(!in_array($period->workflow_status,array('final','posted','paid'),true))throw new BusinessException('Export tersedia setelah payroll berstatus Final.');if(!$this->allSalary()&&$this->hasRestrictedPeriod($id))throw new BusinessException('Export periode ini memuat gaji terbatas.');return$period;}
private function hasRestrictedPeriod($id){return$this->db->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company(),'e.salary_restricted'=>1))->count_all_results()>0;}
private function payrollRows($id,$bank=null){$q=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number,e.bank_account_name,e.salary_restricted')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);if($bank){$q->group_start()->like('e.bank_name',$bank);if($bank==='bri')$q->or_like('e.bank_name','Rakyat Indonesia');if($bank==='bca')$q->or_like('e.bank_name','Central Asia');$q->group_end();}$rows=$q->order_by('e.full_name')->get()->result();if($bank){$missing=array();foreach($rows as$row)if(trim((string)$row->bank_account_number)===''||trim((string)$row->bank_account_name)==='')$missing[]=$row->full_name;if($missing)throw new BusinessException('Data rekening belum lengkap: '.implode(', ',$missing).'.');}return$rows;}
}
@@ -0,0 +1,38 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Inventorycheck extends CI_Controller
{
public function __construct(){parent::__construct();if(!is_cli())show_404();}
public function integrity(){
$checks=array(
'duplicate_barcodes'=>"SELECT COUNT(*) total FROM(SELECT barcode FROM item_barcodes GROUP BY barcode HAVING COUNT(*)>1)x",
'duplicate_ledger_idempotency'=>"SELECT COUNT(*) total FROM(SELECT idempotency_key FROM inventory_ledger GROUP BY idempotency_key HAVING COUNT(*)>1)x",
'negative_ledger_stock'=>"SELECT COUNT(*) total FROM(SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id HAVING qty<-.0001)x",
'ledger_vs_legacy_qty'=>"SELECT COUNT(*) total FROM(SELECT l.item_id,l.warehouse_id,l.qty-COALESCE(s.qty,0) diff FROM(SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id)l LEFT JOIN(SELECT item_id,COALESCE(warehouse_id,0) warehouse_id,SUM(IF(tipe='masuk',qty,-qty)) qty FROM stock_logs GROUP BY item_id,COALESCE(warehouse_id,0))s ON s.item_id=l.item_id AND s.warehouse_id=l.warehouse_id HAVING ABS(diff)>.0001)x",
'item_cache_vs_ledger'=>"SELECT COUNT(*) total FROM(SELECT i.id,i.stok-COALESCE(l.qty,0) diff FROM items i LEFT JOIN(SELECT item_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id)l ON l.item_id=i.id HAVING ABS(diff)>.0001)x",
'barcode_reserved_over_qty'=>"SELECT COUNT(*) total FROM item_barcodes WHERE reserved_qty>qty_sisa+.0001",
'over_released_reservation'=>"SELECT COUNT(*) total FROM stock_reservations WHERE released_qty>qty+.0001",
'duplicate_active_technician_barcode'=>"SELECT COUNT(*) total FROM(SELECT barcode FROM item_technician WHERE status='active' GROUP BY barcode HAVING COUNT(*)>1)x",
'technician_barcode_not_installed'=>"SELECT COUNT(*) total FROM item_technician it JOIN item_barcodes ib ON ib.barcode=it.barcode WHERE it.status='active' AND ib.status<>'installed'",
'cross_company_technician_item'=>"SELECT COUNT(*) total FROM item_technician it JOIN items i ON i.id=it.item_id JOIN k_employees e ON e.id=it.user_id WHERE it.status='active' AND i.company_id<>e.company_id",
'posted_doc_without_ledger'=>"SELECT COUNT(*) total FROM stock_documents d LEFT JOIN inventory_ledger l ON l.document_id=d.id AND l.document_type=d.document_type WHERE d.status='posted' AND l.id IS NULL AND NOT(d.document_type='opname' AND NOT EXISTS(SELECT 1 FROM stock_document_lines x WHERE x.stock_document_id=d.id AND ABS(x.counted_qty-x.system_qty)>.0001))"
);$fail=0;foreach($checks as$n=>$q){$v=(int)$this->db->query($q)->row()->total;echo($v?'[FAIL] ':'[OK] ').$n.'='.$v."\n";if($v)$fail++;}if($fail)exit(1);
}
public function reconciliation(){$ledger=(float)$this->db->query("SELECT COALESCE(SUM(IF(direction='in',value,-value)),0) total FROM inventory_ledger")->row()->total;$m=$this->db->get_where('system_account_mappings',array('mapping_key'=>'inventory','is_active'=>1))->row();$gl=(float)$this->db->query("SELECT COALESCE(SUM(d.debit-d.kredit),0) total FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE d.account_id=? AND j.status IN('posted','reversed')",array($m->account_id))->row()->total;echo'ledger_value='.number_format($ledger,2,'.','')."\n".'gl_value='.number_format($gl,2,'.','')."\n".'difference='.number_format($ledger-$gl,2,'.','')."\n";if(abs($ledger-$gl)>.01)exit(1);}
public function trigger_test(){$row=$this->db->query("SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id HAVING qty>=0 LIMIT 1")->row();if(!$row){echo"[SKIP] no stock row\n";return;}$this->db->trans_begin();$old=$this->db->db_debug;$this->db->db_debug=false;$ok=$this->db->insert('stock_logs',array('item_id'=>$row->item_id,'warehouse_id'=>$row->warehouse_id,'qty'=>$row->qty+999999,'tipe'=>'keluar','keterangan'=>'trigger test','ref_type'=>'test','idempotency_key'=>'TEST-NEGATIVE-'.uniqid()));$this->db->db_debug=$old;$this->db->trans_rollback();echo(!$ok?'[OK] ':'[FAIL] ')."negative_stock_trigger\n";if($ok)exit(1);}
public function professional(){
$company=$this->db->select_min('id')->get('companies')->row();if(!$company){fwrite(STDERR,"Company development tidak tersedia.\n");exit(1);}$company=(int)$company->id;$this->load->model('InventoryModel','inventory');
$required=array('stock_documents'=>array('company_id','source_type','submitted_by','reversal_of_id'),'stock_document_lines'=>array('line_no','movement_direction'),'stock_reservations'=>array('company_id','completed_at'),'stock_logs'=>array('company_id','unit_cost','idempotency_key'),'warehouses'=>array('code','is_active'),'kode_barang'=>array('company_id','is_active'));
$missing=array();foreach($required as$table=>$columns)foreach($columns as$column)if(!$this->db->field_exists($column,$table))$missing[]=$table.'.'.$column;
$orphans=(int)$this->db->query("SELECT COUNT(*) total FROM stock_document_lines l LEFT JOIN stock_documents d ON d.id=l.stock_document_id LEFT JOIN items i ON i.id=l.item_id WHERE d.id IS NULL OR i.id IS NULL")->row()->total;
$wrongCompany=(int)$this->db->query("SELECT COUNT(DISTINCT d.id) total FROM stock_documents d JOIN stock_document_lines l ON l.stock_document_id=d.id JOIN items i ON i.id=l.item_id WHERE d.company_id<>i.company_id")->row()->total;
$duplicateNumbers=(int)$this->db->query("SELECT COUNT(*) total FROM(SELECT company_id,document_no FROM stock_documents GROUP BY company_id,document_no HAVING COUNT(*)>1)x")->row()->total;
$dashboard=$this->inventory->dashboard($company);$valuation=$this->inventory->valuation($company);$reconciliation=$this->inventory->reconciliation($company,date('Y-m-d'));
$ok=!$missing&&$orphans===0&&$wrongCompany===0&&$duplicateNumbers===0;$payload=array('status'=>$ok?'ok':'failed','checks'=>array('missing_columns'=>$missing,'orphan_lines'=>$orphans,'cross_company_documents'=>$wrongCompany,'duplicate_document_numbers'=>$duplicateNumbers),'dashboard'=>$dashboard,'valuation_rows'=>count($valuation),'reconciliation'=>$reconciliation);echo json_encode($payload,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)."\n";if(!$ok)exit(1);
}
public function anomalies(){
$barcode=$this->db->query("SELECT l.item_id,i.kode_detail,i.nama_barang,l.warehouse_id,l.qty,COALESCE(b.qty,0) barcode_qty,l.qty-COALESCE(b.qty,0) difference FROM(SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id)l JOIN items i ON i.id=l.item_id LEFT JOIN(SELECT item_id,warehouse_id,SUM(qty_sisa) qty FROM item_barcodes GROUP BY item_id,warehouse_id)b ON b.item_id=l.item_id AND b.warehouse_id=l.warehouse_id HAVING ABS(difference)>.0001 ORDER BY ABS(difference) DESC")->result();
$technician=$this->db->query("SELECT it.barcode,it.item_id,i.kode_detail,i.nama_barang,it.user_id,e.full_name technician_name,ib.status barcode_status,ib.qty_sisa FROM item_technician it JOIN item_barcodes ib ON ib.barcode=it.barcode JOIN items i ON i.id=it.item_id LEFT JOIN k_employees e ON e.id=it.user_id WHERE it.status='active' AND ib.status<>'installed'")->result();
$payload=array('items_without_company'=>(int)$this->db->where('company_id IS NULL',null,false)->count_all_results('items'),'stock_logs_without_cost'=>(int)$this->db->group_start()->where('unit_cost IS NULL',null,false)->or_where('unit_cost',0)->group_end()->count_all_results('stock_logs'),'barcode_variances'=>$barcode,'technician_status_variances'=>$technician);echo json_encode($payload,JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE)."\n";
}
}
@@ -0,0 +1,205 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Inventoryprofessional extends MY_Controller
{
public function __construct()
{
parent::__construct();
$this->load->library(array('ProfessionalInventoryService','CompanyContext'));
$this->load->model('InventoryModel','inventory');
}
private function uid(){return(int)$this->session->userdata('user_id');}
private function company(){return(int)$this->companycontext->id();}
private function roleId(){$role=(int)$this->session->userdata('role_id');if($role)return$role;$user=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();return$user?(int)$user->role_id:0;}
private function can($action){return$this->session->userdata('role')==='Admin'||check_permission('items',$action);}
private function page($view,$active,array$data=array()){$data['active_menu']=$active;$data['inventory_permissions']=$this->permissionMap();$this->load->view('partials/header',$data);$this->load->view($view,$data);$this->load->view('partials/footer');}
private function permissionMap(){return array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export')) ;}
public function index()
{
$company=$this->company();$this->professionalinventoryservice->expireReservations();
$this->page('inventory_professional/dashboard','inventory_overview',array('summary'=>$this->inventory->dashboard($company),'action_items'=>$this->inventory->actionItems($company),'documents'=>$this->inventory->recentDocuments($company),'last_reconciliation'=>$this->db->where('company_id',$company)->order_by('id','DESC')->get('inventory_reconciliations')->row()));
}
public function operations()
{
$company=$this->company();$warehouses=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('nama')->get('warehouses')->result();
$this->page('inventory_professional/operations','inventory_operations',array('warehouses'=>$warehouses));
}
public function locations()
{
$this->page('inventory_professional/locations','inventory_locations');
}
public function reports()
{
$warehouses=$this->db->where(array('company_id'=>$this->company(),'is_active'=>1))->order_by('nama')->get('warehouses')->result();
$this->page('inventory_professional/reports','inventory_reports',array('warehouses'=>$warehouses));
}
public function document_data()
{
$company=$this->company();$draw=(int)$this->input->get('draw');$start=max(0,(int)$this->input->get('start'));$length=min(100,max(10,(int)$this->input->get('length')));$search=trim((string)($this->input->get('search')['value']??''));$type=trim((string)$this->input->get('type'));$status=trim((string)$this->input->get('status'));
$base=$this->db->from('stock_documents d')->where('d.company_id',$company);if($type==='manual')$base->where_in('d.document_type',array('opening','in','out'));elseif($type==='damage')$base->where_in('d.document_type',array('damaged','lost'));elseif($type!=='')$base->where('d.document_type',$type);if($status!=='')$base->where('d.status',$status);if($search!=='')$base->group_start()->like('d.document_no',$search)->or_like('d.reason',$search)->or_like('d.source_type',$search)->group_end();$filtered=$base->count_all_results('',false);
$rows=$base->select('d.*,wf.nama from_warehouse,wt.nama to_warehouse,u.nama creator_name,(SELECT COUNT(*) FROM stock_document_lines l WHERE l.stock_document_id=d.id) line_count')->join('warehouses wf','wf.id=d.from_warehouse_id','left')->join('warehouses wt','wt.id=d.to_warehouse_id','left')->join('users u','u.id=d.created_by','left')->order_by('d.id','DESC')->limit($length,$start)->get()->result();
$total=$this->db->where('company_id',$company)->count_all_results('stock_documents');return$this->output->set_content_type('application/json')->set_output(json_encode(array('draw'=>$draw,'recordsTotal'=>$total,'recordsFiltered'=>$filtered,'data'=>$rows)));
}
public function document_detail($id)
{
$doc=$this->inventory->documentDetail((int)$id,$this->company());if(!$doc)return json_response(false,'Dokumen stok tidak ditemukan.',array(),404);
foreach($doc->attachments as$a){$a->preview_url=base_url('accountingattachments/preview/'.$a->id);$a->download_url=base_url('accountingattachments/download/'.$a->id);}
return json_response(true,'Detail dokumen stok.',array('document'=>$doc,'permissions'=>$this->permissionMap()));
}
public function create()
{
if(strtoupper($this->input->method())!=='POST')show_404();
$this->requirePermission('items','can_create');$lines=json_decode((string)$this->input->post('lines'),true);if(!is_array($lines))return json_response(false,'Detail barang tidak valid.',array(),422);
try{$id=$this->professionalinventoryservice->createDocument(array('type'=>$this->input->post('type',true),'date'=>$this->input->post('date',true),'from_warehouse_id'=>$this->input->post('from_warehouse_id'),'to_warehouse_id'=>$this->input->post('to_warehouse_id'),'reason'=>$this->input->post('reason',true),'source_type'=>$this->input->post('source_type',true),'source_id'=>$this->input->post('source_id'),'idempotency_key'=>$this->input->post('idempotency_key',true),'lines'=>$lines),$this->uid());return json_response(true,'Dokumen stok berhasil disimpan sebagai draft.',array('document_id'=>$id));}catch(Throwable$e){return business_exception_response($e);}
}
public function submit($id){$this->requirePermission('items','can_create');return$this->action(fn()=>$this->professionalinventoryservice->submit($id,$this->uid()),'Dokumen stok berhasil diajukan.');}
public function approve($id){$this->requirePermission('items','can_approve');return$this->action(fn()=>$this->professionalinventoryservice->approve($id,$this->uid(),$this->roleId()),'Dokumen stok berhasil disetujui.');}
public function reject($id){$this->requirePermission('items','can_approve');return$this->action(fn()=>$this->professionalinventoryservice->reject($id,$this->uid(),$this->roleId(),$this->input->post('reason',true)),'Dokumen stok ditolak.');}
public function post($id){$this->requirePermission('items','can_post');return$this->action(fn()=>$this->professionalinventoryservice->post($id,$this->uid()),'Mutasi stok berhasil diposting.');}
public function reverse($id){$this->requirePermission('items','can_post');return$this->action(fn()=>$this->professionalinventoryservice->reverse($id,$this->input->post('date',true),$this->input->post('reason',true),$this->uid()),'Reversal stok berhasil diposting.');}
public function lookup_items()
{
$q=trim((string)$this->input->get('q'));$warehouse=(int)$this->input->get('warehouse_id');$this->db->select("i.id,CONCAT(i.kode_detail,' - ',i.nama_barang) text,i.nama_barang,i.kode_detail,i.harga_beli,i.allow_decimal,k.tracking_type,k.need_serial_number,COALESCE(s.qty,0) on_hand,COALESCE(r.qty,0) reserved",false)->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->join("(SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id)s",'s.item_id=i.id'.($warehouse?' AND s.warehouse_id='.(int)$warehouse:''),'left')->join("(SELECT item_id,warehouse_id,SUM(qty-released_qty) qty FROM stock_reservations WHERE status IN('active','partially_released') GROUP BY item_id,warehouse_id)r",'r.item_id=i.id'.($warehouse?' AND r.warehouse_id='.(int)$warehouse:''),'left')->where(array('i.company_id'=>$this->company(),'i.status'=>'active'));if($q!=='')$this->db->group_start()->like('i.kode_detail',$q)->or_like('i.nama_barang',$q)->group_end();$rows=$this->db->group_by('i.id')->order_by('i.nama_barang')->limit(25)->get()->result();return json_response(true,'Daftar barang.',array('results'=>$rows));
}
public function lookup_barcodes()
{
$item=(int)$this->input->get('item_id');$warehouse=(int)$this->input->get('warehouse_id');$q=trim((string)$this->input->get('q'));$this->assertItem($item);
$this->db->select("b.id,b.barcode,b.serial_number,b.qty_sisa,b.reserved_qty,b.return_reserved_qty,b.status,CONCAT(b.barcode,IF(b.serial_number IS NULL OR b.serial_number='','',CONCAT(' / SN ',b.serial_number)),' - tersedia ',FORMAT(GREATEST(b.qty_sisa-b.reserved_qty-b.return_reserved_qty,0),2,'id_ID')) text",false)->from('item_barcodes b')->join('items i','i.id=b.item_id')->where(array('b.item_id'=>$item,'i.company_id'=>$this->company()))->where_in('b.status',array('available','reserved'))->where('b.qty_sisa >',0)->where('(b.qty_sisa-COALESCE(b.reserved_qty,0)-COALESCE(b.return_reserved_qty,0)) > 0',null,false);
if($warehouse)$this->db->where('b.warehouse_id',$warehouse);if($q!=='')$this->db->group_start()->like('b.barcode',$q)->or_like('b.serial_number',$q)->group_end();$rows=$this->db->order_by('b.id')->limit(30)->get()->result();return json_response(true,'Daftar barcode.',array('results'=>$rows));
}
public function lookup_batches()
{
$item=(int)$this->input->get('item_id');$q=trim((string)$this->input->get('q'));$this->db->select("id,CONCAT(batch_no,IF(expiry_date IS NULL,'',CONCAT(' · exp ',DATE_FORMAT(expiry_date,'%d/%m/%Y')))) text,batch_no,expiry_date",false)->where(array('company_id'=>$this->company(),'item_id'=>$item,'is_active'=>1));if($q!=='')$this->db->like('batch_no',$q);$rows=$this->db->order_by('id','DESC')->limit(25)->get('inventory_batches')->result();return json_response(true,'Daftar batch.',array('results'=>$rows));
}
public function scan()
{
$code=trim((string)$this->input->get('code'));
if($code==='')return json_response(false,'Barcode atau serial wajib diisi.',array(),422);
try{
$select='b.*,i.kode_detail,i.nama_barang,k.unit,w.nama warehouse_name';
$this->db->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('kode_barang k','k.id=i.kode_id','left')->join('warehouses w','w.id=b.warehouse_id','left');
if($this->db->table_exists('warehouse_bins')&&$this->db->field_exists('bin_id','item_barcodes')){$select.=',wb.code bin_code';$this->db->join('warehouse_bins wb','wb.id=b.bin_id','left');}else$select.=',NULL bin_code';
if($this->db->table_exists('inventory_batches')&&$this->db->field_exists('batch_id','item_barcodes')){$select.=',bt.batch_no';$this->db->join('inventory_batches bt','bt.id=b.batch_id','left');}else$select.=',NULL batch_no';
$row=$this->db->select($select,false)->where('i.company_id',$this->company())->group_start()->where('b.barcode',$code)->or_where('b.serial_number',$code)->group_end()->limit(1)->get()->row();
if(!$row)return json_response(false,'Barcode atau serial tidak ditemukan pada perusahaan aktif.',array(),404);
$row->reserved_qty=(float)($row->reserved_qty??0);$row->return_reserved_qty=(float)($row->return_reserved_qty??0);$row->qty_sisa=(float)($row->qty_sisa??0);$row->history=array();
if($this->db->table_exists('item_movements')&&$this->db->field_exists('barcode_id','item_movements')){
$type=$this->db->field_exists('movement_type','item_movements')?'m.movement_type':"'Perpindahan'";
$notes=$this->db->field_exists('notes','item_movements')?'m.notes':"''";
$created=$this->db->field_exists('created_at','item_movements')?'m.created_at':'NULL';
$row->history=$this->db->select($type.' movement_type,'.$notes.' notes,'.$created.' created_at',false)->from('item_movements m')->join('items mi','mi.id=m.item_id')->where(array('m.barcode_id'=>$row->id,'mi.company_id'=>$this->company()))->order_by('m.id','DESC')->limit(20)->get()->result();
}
if(!$row->history&&$this->db->table_exists('stock_logs')&&$this->db->field_exists('barcode_id','stock_logs')){
$type=$this->db->field_exists('tipe','stock_logs')?'s.tipe':"'Mutasi Stok'";
$notes=$this->db->field_exists('keterangan','stock_logs')?'s.keterangan':"''";
$created=$this->db->field_exists('created_at','stock_logs')?'s.created_at':($this->db->field_exists('tanggal','stock_logs')?'s.tanggal':'NULL');
$row->history=$this->db->select($type.' movement_type,'.$notes.' notes,'.$created.' created_at',false)->from('stock_logs s')->join('items si','si.id=s.item_id')->where(array('s.barcode_id'=>$row->id,'si.company_id'=>$this->company()))->order_by('s.id','DESC')->limit(20)->get()->result();
}
return json_response(true,'Barcode ditemukan.',array('item'=>$row));
}catch(Throwable$e){return business_exception_response($e);}
}
public function reservation_data()
{
$rows=$this->db->select('r.*,i.kode_detail,i.nama_barang,w.nama warehouse_name,b.barcode,b.serial_number')->from('stock_reservations r')->join('items i','i.id=r.item_id')->join('warehouses w','w.id=r.warehouse_id')->join('item_barcodes b','b.id=r.barcode_id','left')->where('r.company_id',$this->company())->order_by('r.id','DESC')->limit(300)->get()->result();return json_response(true,'Daftar reservasi.',array('data'=>$rows));
}
public function reserve(){if(strtoupper($this->input->method())!=='POST')show_404();$this->requirePermission('items','can_create');try{$id=$this->professionalinventoryservice->reserve($this->input->post('item_id'),$this->input->post('warehouse_id'),$this->input->post('barcode_id'),$this->input->post('qty'),$this->input->post('source_type',true),$this->input->post('source_id'),$this->input->post('expires_at'),$this->uid(),$this->input->post('notes',true));return json_response(true,'Stok berhasil direservasi.',array('reservation_id'=>$id));}catch(Throwable$e){return business_exception_response($e);}}
public function update_reservation($id){$this->requirePermission('items','can_update');return$this->action(fn()=>$this->professionalinventoryservice->reservationAction($id,$this->input->post('action',true),$this->input->post('qty'),$this->input->post('notes',true),$this->uid()),'Reservasi berhasil diperbarui.');}
public function master_data()
{
$company=$this->company();$warehouses=$this->db->where('company_id',$company)->order_by('nama')->get('warehouses')->result();$bins=$this->db->select('b.*,w.nama warehouse_name')->from('warehouse_bins b')->join('warehouses w','w.id=b.warehouse_id')->where('b.company_id',$company)->order_by('w.nama')->order_by('b.code')->get()->result();$batches=$this->db->select('b.*,i.kode_detail,i.nama_barang,s.name supplier_name')->from('inventory_batches b')->join('items i','i.id=b.item_id')->join('suppliers s','s.id=b.supplier_id','left')->where('b.company_id',$company)->order_by('b.id','DESC')->limit(300)->get()->result();return json_response(true,'Master lokasi persediaan.',array('warehouses'=>$warehouses,'bins'=>$bins,'batches'=>$batches));
}
public function save_warehouse(){return$this->masterAction(function(){$id=(int)$this->input->post('id');$data=array('company_id'=>$this->company(),'code'=>strtoupper(trim((string)$this->input->post('code',true))),'nama'=>trim((string)$this->input->post('name',true)),'address'=>trim((string)$this->input->post('address',true)),'is_active'=>$this->input->post('is_active')?1:0);if($data['code']===''||$data['nama']==='')throw new BusinessException('Kode dan nama gudang wajib diisi.');$q=$this->db->where(array('company_id'=>$data['company_id'],'code'=>$data['code']))->where('id !=',$id)->get('warehouses')->row();if($q)throw new BusinessException('Kode gudang sudah digunakan.');if($id){$this->db->where(array('id'=>$id,'company_id'=>$data['company_id']))->update('warehouses',$data);return$id;}$this->db->insert('warehouses',$data);return$this->db->insert_id();},'Gudang berhasil disimpan.');}
public function save_bin(){return$this->masterAction(function(){$id=(int)$this->input->post('id');$warehouse=(int)$this->input->post('warehouse_id');$this->assertWarehouse($warehouse);$data=array('company_id'=>$this->company(),'warehouse_id'=>$warehouse,'code'=>strtoupper(trim((string)$this->input->post('code',true))),'name'=>trim((string)$this->input->post('name',true)),'is_active'=>$this->input->post('is_active')?1:0,'updated_at'=>date('Y-m-d H:i:s'));if($data['code']==='')throw new BusinessException('Kode rak/bin wajib diisi.');$q=$this->db->where(array('warehouse_id'=>$warehouse,'code'=>$data['code']))->where('id !=',$id)->get('warehouse_bins')->row();if($q)throw new BusinessException('Kode rak sudah digunakan di gudang ini.');if($id){$this->db->where(array('id'=>$id,'company_id'=>$data['company_id']))->update('warehouse_bins',$data);return$id;}$this->db->insert('warehouse_bins',$data);return$this->db->insert_id();},'Lokasi rak berhasil disimpan.');}
public function save_batch(){return$this->masterAction(function(){$id=(int)$this->input->post('id');$item=(int)$this->input->post('item_id');$this->assertItem($item);$data=array('company_id'=>$this->company(),'item_id'=>$item,'batch_no'=>strtoupper(trim((string)$this->input->post('batch_no',true))),'manufactured_date'=>$this->input->post('manufactured_date')?:null,'expiry_date'=>$this->input->post('expiry_date')?:null,'supplier_id'=>(int)$this->input->post('supplier_id')?:null,'is_active'=>$this->input->post('is_active')?1:0,'updated_at'=>date('Y-m-d H:i:s'));if($data['batch_no']==='')throw new BusinessException('Nomor batch wajib diisi.');if($data['manufactured_date']&&$data['expiry_date']&&$data['expiry_date']<$data['manufactured_date'])throw new BusinessException('Tanggal kedaluwarsa tidak boleh sebelum tanggal produksi.');$q=$this->db->where(array('item_id'=>$item,'batch_no'=>$data['batch_no']))->where('id !=',$id)->get('inventory_batches')->row();if($q)throw new BusinessException('Nomor batch sudah digunakan untuk barang ini.');if($id){$this->db->where(array('id'=>$id,'company_id'=>$data['company_id']))->update('inventory_batches',$data);return$id;}$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('inventory_batches',$data);return$this->db->insert_id();},'Batch berhasil disimpan.');}
public function report_data()
{
try{$type=$this->input->get('type')?:'valuation';$filters=array('warehouse_id'=>(int)$this->input->get('warehouse_id'),'item_id'=>(int)$this->input->get('item_id'),'date_from'=>$this->input->get('date_from')?:date('Y-m-01'),'date_to'=>$this->input->get('date_to')?:date('Y-m-d'),'days'=>max(30,(int)$this->input->get('days')));$rows=$this->buildReport($type,$filters);return json_response(true,'Laporan persediaan.',array('data'=>$rows));}catch(Throwable$e){return business_exception_response($e);}
}
public function stock_card($item)
{
$this->assertItem((int)$item);$rows=$this->db->select('l.*,w.nama warehouse_name,d.document_no,d.reason')->from('inventory_ledger l')->join('items i','i.id=l.item_id')->join('warehouses w','w.id=l.warehouse_id','left')->join('stock_documents d','d.id=l.document_id AND d.document_type=l.document_type','left')->where(array('l.item_id'=>(int)$item,'i.company_id'=>$this->company()))->order_by('l.movement_date')->order_by('l.id')->get()->result();return json_response(true,'Kartu stok.',array('rows'=>$rows));
}
public function reconciliation()
{
$this->requirePermission('items','can_post');$date=$this->input->post('date',true)?:date('Y-m-d');try{$result=$this->inventory->reconciliation($this->company(),$date);$this->db->insert('inventory_reconciliations',array('company_id'=>$this->company(),'as_of_date'=>$date,'ledger_value'=>$result['ledger_value'],'stock_log_value'=>$result['stock_log_value'],'gl_value'=>$result['gl_value'],'difference'=>$result['difference'],'barcode_variance_count'=>$result['barcode_variance_count'],'cache_variance_count'=>$result['cache_variance_count'],'status'=>$result['status'],'notes'=>$result['status']==='matched'?'Semua kontrol cocok':'Perlu tindak lanjut; tidak ada jurnal koreksi otomatis.','created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s')));return json_response(true,'Rekonsiliasi selesai tanpa membuat jurnal koreksi.',array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);}
}
public function document_pdf($id)
{
$this->requirePermission('items','can_export');$doc=$this->inventory->documentDetail((int)$id,$this->company());if(!$doc)show_404();$this->inlinePdf($this->load->view('inventory_professional/document_pdf',array('document'=>$doc),true),'Dokumen-Stok-'.$doc->document_no.'.pdf');
}
public function report_pdf()
{
$this->requirePermission('items','can_export');$type=$this->input->get('type')?:'valuation';$filters=array('warehouse_id'=>(int)$this->input->get('warehouse_id'),'item_id'=>(int)$this->input->get('item_id'),'date_from'=>$this->input->get('date_from')?:date('Y-m-01'),'date_to'=>$this->input->get('date_to')?:date('Y-m-d'),'days'=>max(30,(int)$this->input->get('days')));$definition=$this->reportDefinition($type);$rows=$this->buildReport($type,$filters);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();$html=$this->load->view('inventory_professional/report_pdf',array('rows'=>$rows,'filters'=>$filters,'definition'=>$definition,'company'=>$company),true);$this->inlinePdf($html,'Laporan-Persediaan-'.$type.'-'.date('Ymd').'.pdf');
}
public function report_excel()
{
$this->requirePermission('items','can_export');$type=$this->input->get('type')?:'valuation';$filters=array('warehouse_id'=>(int)$this->input->get('warehouse_id'),'item_id'=>(int)$this->input->get('item_id'),'date_from'=>$this->input->get('date_from')?:date('Y-m-01'),'date_to'=>$this->input->get('date_to')?:date('Y-m-d'),'days'=>max(30,(int)$this->input->get('days')));$definition=$this->reportDefinition($type);$rows=$this->buildReport($type,$filters);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();
require_once FCPATH.'vendor/autoload.php';$book=new \PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$book->getActiveSheet();$sheet->setTitle('Persediaan');$last=\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex(max(1,count($definition['columns'])));$companyName=$company?($company->name??$company->company_name??'Perusahaan'):'Perusahaan';$sheet->setCellValue('A1',$companyName);$sheet->mergeCells('A1:'.$last.'1');$sheet->setCellValue('A2',$definition['title']);$sheet->mergeCells('A2:'.$last.'2');$sheet->setCellValue('A3','Periode: '.$filters['date_from'].' s.d. '.$filters['date_to']);$sheet->mergeCells('A3:'.$last.'3');$sheet->getStyle('A1:'.$last.'2')->getFont()->setBold(true);
$column=1;foreach($definition['columns']as$label){$sheet->setCellValueByColumnAndRow($column++,5,$label);} $sheet->getStyle('A5:'.$last.'5')->getFont()->setBold(true);$rowNumber=6;foreach($rows as$row){$column=1;foreach($definition['columns']as$key=>$label){$value=is_array($row)?($row[$key]??null):($row->$key??null);$sheet->setCellValueByColumnAndRow($column++,$rowNumber,$value);} $rowNumber++;}for($column=1;$column<=count($definition['columns']);$column++)$sheet->getColumnDimension(\PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($column))->setAutoSize(true);$sheet->freezePane('A6');$sheet->setAutoFilter('A5:'.$last.max(5,$rowNumber-1));
while(ob_get_level()>0)ob_end_clean();$filename='Laporan-Persediaan-'.$type.'-'.date('Ymd-His').'.xlsx';header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="'.$filename.'"');header('Cache-Control: max-age=0');\PhpOffice\PhpSpreadsheet\IOFactory::createWriter($book,'Xlsx')->save('php://output');$book->disconnectWorksheets();exit;
}
private function buildReport($type,array$filters)
{
$allowed=array_keys($this->reportDefinition());if(!in_array($type,$allowed,true))throw new BusinessException('Jenis laporan persediaan tidak valid.');$company=$this->company();$warehouse=(int)$filters['warehouse_id'];$item=(int)$filters['item_id'];$from=$filters['date_from'];$to=$filters['date_to'];if($from>$to)throw new BusinessException('Tanggal awal tidak boleh melewati tanggal akhir.');if($item)$this->assertItem($item);
if($type==='valuation'){$params=array($to,$company);$where='';if($warehouse){$where.=' AND l.warehouse_id=?';$params[]=$warehouse;}if($item){$where.=' AND l.item_id=?';$params[]=$item;}return$this->db->query("SELECT i.id,i.kode_detail,i.nama_barang,w.nama warehouse_name,i.costing_method,i.reorder_point,SUM(IF(l.direction='in',l.qty,-l.qty)) qty,SUM(IF(l.direction='in',l.value,-l.value)) inventory_value FROM inventory_ledger l JOIN items i ON i.id=l.item_id JOIN warehouses w ON w.id=l.warehouse_id WHERE l.movement_date<=? AND i.company_id=? {$where} GROUP BY i.id,i.kode_detail,i.nama_barang,w.id,w.nama,i.costing_method,i.reorder_point ORDER BY i.nama_barang,w.nama",$params)->result();}
if($type==='stock_card'){if(!$item)throw new BusinessException('Pilih barang untuk menampilkan kartu stok.');$this->db->select("l.*,w.nama warehouse_name,COALESCE(d.document_no,CONCAT(UPPER(REPLACE(l.document_type,'_',' ')),' #',l.document_id)) document_no,d.reason",false)->from('inventory_ledger l')->join('warehouses w','w.id=l.warehouse_id','left')->join('stock_documents d','d.id=l.document_id AND d.document_type=l.document_type','left')->where('l.item_id',$item)->where('l.movement_date >=',$from)->where('l.movement_date <=',$to);if($warehouse)$this->db->where('l.warehouse_id',$warehouse);return$this->db->order_by('l.movement_date')->order_by('l.id')->get()->result();}
if($type==='location'){$params=array($to,$company);$where='';if($warehouse){$where.=' AND l.warehouse_id=?';$params[]=$warehouse;}if($item){$where.=' AND l.item_id=?';$params[]=$item;}return$this->db->query("SELECT i.kode_detail,i.nama_barang,w.nama warehouse_name,COALESCE(b.code,'Tanpa Rak') bin_code,SUM(IF(l.direction='in',l.qty,-l.qty)) qty FROM inventory_ledger l JOIN items i ON i.id=l.item_id JOIN warehouses w ON w.id=l.warehouse_id LEFT JOIN warehouse_bins b ON b.id=l.bin_id WHERE l.movement_date<=? AND i.company_id=? {$where} GROUP BY i.id,i.kode_detail,i.nama_barang,w.id,w.nama,b.id,b.code HAVING ABS(qty)>.0001 ORDER BY w.nama,bin_code,i.nama_barang",$params)->result();}
if($type==='barcode'){$this->db->select('b.barcode,b.serial_number,b.status,b.qty_awal,b.qty_sisa,b.reserved_qty,b.return_reserved_qty,i.kode_detail,i.nama_barang,w.nama warehouse_name,wb.code bin_code,bt.batch_no')->from('item_barcodes b')->join('items i','i.id=b.item_id')->join('warehouses w','w.id=b.warehouse_id','left')->join('warehouse_bins wb','wb.id=b.bin_id','left')->join('inventory_batches bt','bt.id=b.batch_id','left')->where('i.company_id',$company);if($warehouse)$this->db->where('b.warehouse_id',$warehouse);if($item)$this->db->where('b.item_id',$item);return$this->db->order_by('b.id','DESC')->limit(5000)->get()->result();}
if($type==='batch'){$this->db->select('b.batch_no,b.manufactured_date,b.expiry_date,b.is_active,i.kode_detail,i.nama_barang,w.nama warehouse_name,COALESCE(SUM(ib.qty_sisa),0) qty',false)->from('inventory_batches b')->join('items i','i.id=b.item_id')->join('item_barcodes ib','ib.batch_id=b.id','left')->join('warehouses w','w.id=ib.warehouse_id','left')->where('b.company_id',$company);if($warehouse)$this->db->where('ib.warehouse_id',$warehouse);if($item)$this->db->where('b.item_id',$item);return$this->db->group_by(array('b.id','b.batch_no','b.manufactured_date','b.expiry_date','b.is_active','i.kode_detail','i.nama_barang','w.id','w.nama'))->order_by('b.expiry_date')->get()->result();}
if($type==='reservation'){$this->db->select('r.reservation_no,r.source_type,r.source_id,r.qty,r.released_qty,r.expires_at,r.status,i.kode_detail,i.nama_barang,w.nama warehouse_name,b.barcode,b.serial_number')->from('stock_reservations r')->join('items i','i.id=r.item_id')->join('warehouses w','w.id=r.warehouse_id')->join('item_barcodes b','b.id=r.barcode_id','left')->where('r.company_id',$company);if($warehouse)$this->db->where('r.warehouse_id',$warehouse);if($item)$this->db->where('r.item_id',$item);return$this->db->order_by('r.id','DESC')->get()->result();}
if($type==='reorder'){$params=array($to,$company);$where='';if($warehouse){$where.=' AND l.warehouse_id=?';$params[]=$warehouse;}if($item){$where.=' AND l.item_id=?';$params[]=$item;}return$this->db->query("SELECT i.kode_detail,i.nama_barang,w.nama warehouse_name,SUM(IF(l.direction='in',l.qty,-l.qty)) qty,i.reorder_point,i.minimum_stock FROM items i JOIN inventory_ledger l ON l.item_id=i.id JOIN warehouses w ON w.id=l.warehouse_id WHERE l.movement_date<=? AND i.company_id=? {$where} GROUP BY i.id,i.kode_detail,i.nama_barang,w.id,w.nama,i.reorder_point,i.minimum_stock HAVING qty<=i.reorder_point ORDER BY qty",$params)->result();}
if($type==='slow'){$params=array($company,(int)$filters['days']);$where='';if($item){$where.=' AND i.id=?';$params[]=$item;}return$this->db->query("SELECT i.kode_detail,i.nama_barang,i.last_movement_at,DATEDIFF(CURDATE(),COALESCE(DATE(i.last_movement_at),DATE(i.created_at))) idle_days,i.stok FROM items i WHERE i.company_id=? AND DATEDIFF(CURDATE(),COALESCE(DATE(i.last_movement_at),DATE(i.created_at)))>=? {$where} ORDER BY idle_days DESC",$params)->result();}
if($type==='damage'){$this->db->select('d.id document_id,d.document_no,d.document_date,d.document_type,d.status,d.reason,i.kode_detail,i.nama_barang,w.nama warehouse_name,l.qty,l.unit_cost,(l.qty*l.unit_cost) value',false)->from('stock_documents d')->join('stock_document_lines l','l.stock_document_id=d.id')->join('items i','i.id=l.item_id')->join('warehouses w','w.id=d.from_warehouse_id','left')->where('d.company_id',$company)->where_in('d.document_type',array('damaged','lost'))->where('d.document_date >=',$from)->where('d.document_date <=',$to);if($warehouse)$this->db->where('d.from_warehouse_id',$warehouse);if($item)$this->db->where('l.item_id',$item);return$this->db->order_by('d.id','DESC')->get()->result();}
if($type==='opname'){$this->db->select('d.id document_id,d.document_no,d.document_date,d.document_type,d.status,d.reason,i.kode_detail,i.nama_barang,w.nama warehouse_name,l.system_qty,l.counted_qty,(l.counted_qty-l.system_qty) difference,l.unit_cost',false)->from('stock_documents d')->join('stock_document_lines l','l.stock_document_id=d.id')->join('items i','i.id=l.item_id')->join('warehouses w','w.id=d.from_warehouse_id','left')->where(array('d.company_id'=>$company,'d.document_type'=>'opname'))->where('d.document_date >=',$from)->where('d.document_date <=',$to);if($warehouse)$this->db->where('d.from_warehouse_id',$warehouse);if($item)$this->db->where('l.item_id',$item);return$this->db->order_by('d.id','DESC')->get()->result();}
return$this->db->where('company_id',$company)->where('as_of_date >=',$from)->where('as_of_date <=',$to)->order_by('id','DESC')->limit(200)->get('inventory_reconciliations')->result();
}
private function reportDefinition($type=null)
{
$all=array(
'valuation'=>array('title'=>'Nilai Persediaan','columns'=>array('kode_detail'=>'Kode','nama_barang'=>'Barang','warehouse_name'=>'Gudang','costing_method'=>'Metode','qty'=>'Qty','inventory_value'=>'Nilai')),
'stock_card'=>array('title'=>'Kartu Stok','columns'=>array('movement_date'=>'Tanggal','document_no'=>'Dokumen','warehouse_name'=>'Gudang','direction'=>'Arah','qty'=>'Qty','unit_cost'=>'Biaya Satuan','value'=>'Nilai','reason'=>'Keterangan')),
'location'=>array('title'=>'Stok per Gudang dan Rak','columns'=>array('kode_detail'=>'Kode','nama_barang'=>'Barang','warehouse_name'=>'Gudang','bin_code'=>'Rak/Bin','qty'=>'Qty')),
'barcode'=>array('title'=>'Riwayat Barcode dan Serial','columns'=>array('kode_detail'=>'Kode','nama_barang'=>'Barang','barcode'=>'Barcode','serial_number'=>'Serial','warehouse_name'=>'Gudang','bin_code'=>'Rak','batch_no'=>'Batch','qty_sisa'=>'Qty','reserved_qty'=>'Reservasi','status'=>'Status')),
'batch'=>array('title'=>'Batch dan Kedaluwarsa','columns'=>array('kode_detail'=>'Kode','nama_barang'=>'Barang','batch_no'=>'Batch','warehouse_name'=>'Gudang','manufactured_date'=>'Produksi','expiry_date'=>'Kedaluwarsa','qty'=>'Qty')),
'reservation'=>array('title'=>'Reservasi Stok','columns'=>array('reservation_no'=>'Reservasi','kode_detail'=>'Kode','nama_barang'=>'Barang','warehouse_name'=>'Gudang','source_type'=>'Sumber','source_id'=>'ID Sumber','qty'=>'Qty','released_qty'=>'Diselesaikan','expires_at'=>'Berlaku Sampai','status'=>'Status')),
'reorder'=>array('title'=>'Stok Minimum dan Reorder','columns'=>array('kode_detail'=>'Kode','nama_barang'=>'Barang','warehouse_name'=>'Gudang','qty'=>'Qty','reorder_point'=>'Reorder Point','minimum_stock'=>'Minimum')),
'slow'=>array('title'=>'Slow-moving dan Dead Stock','columns'=>array('kode_detail'=>'Kode','nama_barang'=>'Barang','stok'=>'Stok','last_movement_at'=>'Terakhir Bergerak','idle_days'=>'Tidak Bergerak (hari)')),
'damage'=>array('title'=>'Barang Rusak dan Hilang','columns'=>array('document_no'=>'Dokumen','document_date'=>'Tanggal','document_type'=>'Jenis','kode_detail'=>'Kode','nama_barang'=>'Barang','warehouse_name'=>'Gudang','qty'=>'Qty','value'=>'Nilai','status'=>'Status','reason'=>'Alasan')),
'opname'=>array('title'=>'Stock Opname','columns'=>array('document_no'=>'Dokumen','document_date'=>'Tanggal','kode_detail'=>'Kode','nama_barang'=>'Barang','warehouse_name'=>'Gudang','system_qty'=>'Stok Sistem','counted_qty'=>'Fisik','difference'=>'Selisih','status'=>'Status')),
'reconciliation'=>array('title'=>'Rekonsiliasi Persediaan','columns'=>array('as_of_date'=>'Tanggal','ledger_value'=>'Ledger','stock_log_value'=>'Stock Log','gl_value'=>'General Ledger','difference'=>'Selisih','barcode_variance_count'=>'Selisih Barcode','cache_variance_count'=>'Selisih Cache','status'=>'Status'))
);if($type===null)return$all;if(!isset($all[$type]))throw new BusinessException('Jenis laporan persediaan tidak valid.');return$all[$type];
}
private function inlinePdf($html,$filename){require_once FCPATH.'vendor/autoload.php';$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();$pdf->stream($filename,array('Attachment'=>false));}
private function action($callable,$message){if(strtoupper($this->input->method())!=='POST')show_404();try{$result=$callable();return json_response(true,$message,array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);}}
private function masterAction($callable,$message){$this->requirePermission('items','can_update');return$this->action($callable,$message);}
private function assertWarehouse($id){$row=$this->db->get_where('warehouses',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Gudang tidak ditemukan.');return$row;}
private function assertItem($id){$row=$this->db->get_where('items',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Barang tidak ditemukan.');return$row;}
}
@@ -0,0 +1,9 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Inventoryrepair extends CI_Controller
{
public function __construct(){parent::__construct();if(!is_cli())show_404();$this->load->library('ProfessionalInventoryService');}
public function negative_opening(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$rows=$this->db->query("SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id HAVING qty<-.0001")->result();$user=$this->db->select('users.id')->from('users')->join('roles','roles.id=users.role_id')->where('roles.nama_role','Admin')->order_by('users.id')->get()->row();if(!$user)throw new RuntimeException('User Admin tidak tersedia.');foreach($rows as$r){$item=$this->db->get_where('items',array('id'=>$r->item_id))->row();$key='DEV-NEGATIVE-OPENING-'.$r->item_id.'-'.$r->warehouse_id;$id=$this->professionalinventoryservice->createDocument(array('type'=>'opening','date'=>date('Y-m-d'),'from_warehouse_id'=>null,'to_warehouse_id'=>$r->warehouse_id,'item_id'=>$r->item_id,'barcode_id'=>null,'batch_id'=>null,'from_bin_id'=>null,'to_bin_id'=>null,'qty'=>abs($r->qty),'unit_cost'=>$item->harga_beli,'reason'=>'Koreksi saldo negatif legacy hasil backfill Tahap 7','idempotency_key'=>$key),$user->id);$doc=$this->db->get_where('stock_documents',array('id'=>$id))->row();if($doc->status==='draft')$this->professionalinventoryservice->submit($id,$user->id);$doc=$this->db->get_where('stock_documents',array('id'=>$id))->row();if($doc->status==='approved')$this->professionalinventoryservice->post($id,$user->id);echo'Corrected item '.$r->item_id.' warehouse '.$r->warehouse_id.' qty '.abs($r->qty)."\n";}}
public function reconcile_gl(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$existing=$this->db->where('ref_type','inventory_reconciliation')->where('ref_code','DEV-STAGE7-BASELINE')->get('journals')->row();if($existing){echo"Baseline reconciliation sudah tersedia.\n";return;}$ledger=(float)$this->db->query("SELECT COALESCE(SUM(IF(direction='in',value,-value)),0) total FROM inventory_ledger")->row()->total;$inventory=$this->db->get_where('system_account_mappings',array('mapping_key'=>'inventory','is_active'=>1))->row()->account_id;$gl=(float)$this->db->query("SELECT COALESCE(SUM(d.debit-d.kredit),0) total FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE d.account_id=? AND j.status IN('posted','reversed')",array($inventory))->row()->total;$diff=round($ledger-$gl,2);if(abs($diff)<.01){echo"Ledger dan GL sudah balance.\n";return;}$user=$this->db->select('users.id')->from('users')->join('roles','roles.id=users.role_id')->where('roles.nama_role','Admin')->order_by('users.id')->get()->row();$other=$this->db->get_where('system_account_mappings',array('mapping_key'=>$diff>0?'inventory_adjustment_gain':'inventory_adjustment_loss','is_active'=>1))->row()->account_id;$this->load->library('PostingService');$lines=$diff>0?array(array('account_id'=>$inventory,'debit'=>$diff,'kredit'=>0),array('account_id'=>$other,'debit'=>0,'kredit'=>$diff)):array(array('account_id'=>$other,'debit'=>abs($diff),'kredit'=>0),array('account_id'=>$inventory,'debit'=>0,'kredit'=>abs($diff)));$id=$this->postingservice->post(array('tanggal'=>date('Y-m-d'),'no_ref'=>'INV-RECON-STAGE7-'.date('Ymd'),'keterangan'=>'Baseline rekonsiliasi inventory ledger legacy Tahap 7','ref_type'=>'inventory_reconciliation','ref_id'=>1,'created_by'=>$user->id),$lines,true,true);$this->db->where('id',$id)->update('journals',array('ref_code'=>'DEV-STAGE7-BASELINE'));$this->db->insert('inventory_reconciliations',array('as_of_date'=>date('Y-m-d'),'ledger_value'=>$ledger,'gl_value'=>$ledger,'difference'=>0,'status'=>'resolved','notes'=>'Development baseline; selisih awal '.number_format($diff,2,'.',''),'created_by'=>$user->id,'created_at'=>date('Y-m-d H:i:s')));echo'Jurnal rekonsiliasi dibuat: '.number_format($diff,2,'.','')."\n";}
public function sync_cache(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo"Ditolak: repair khusus Development.\n";return;}$rows=$this->db->query("SELECT i.id,COALESCE(SUM(IF(l.direction='in',l.qty,-l.qty)),0) qty FROM items i LEFT JOIN inventory_ledger l ON l.item_id=i.id GROUP BY i.id")->result();$changed=0;foreach($rows as$r){$item=$this->db->get_where('items',array('id'=>$r->id))->row();if(abs((float)$item->stok-(float)$r->qty)>.0001){$this->db->where('id',$r->id)->update('items',array('stok'=>$r->qty));$changed++;}}echo"Selesai: {$changed} cache stok disinkronkan.\n";}
}
File diff suppressed because it is too large Load Diff
+249 -99
View File
@@ -1,7 +1,9 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Items extends CI_Controller {
class Items extends MY_Controller {
private function companyId(){ return (int)$this->session->userdata('company_id'); }
private function companyItem($id){ return $this->db->get_where('items',array('id'=>(int)$id,'company_id'=>$this->companyId()))->row(); }
public function __construct()
{
@@ -30,6 +32,7 @@ class Items extends CI_Controller {
public function item_barcodes($item_id)
{
if(!$this->companyItem($item_id)) show_404();
$data = [
"active_menu" => "draft_items",
"item_id" => $item_id
@@ -76,8 +79,9 @@ class Items extends CI_Controller {
")
->from('item_barcodes')
->join('items','items.id=item_barcodes.item_id')
->where('item_barcodes.barcode',$barcode)
->or_where('item_barcodes.serial_number',$barcode)
->where('items.company_id',$this->companyId())
->group_start()->where('item_barcodes.barcode',$barcode)
->or_where('item_barcodes.serial_number',$barcode)->group_end()
->get()
->row();
@@ -134,10 +138,7 @@ class Items extends CI_Controller {
public function load_data_barcode($item_id)
{
$item = $this->db
->where('id', $item_id)
->get('items')
->row();
$item = $this->companyItem($item_id);
if (!$item) {
echo json_encode([
@@ -149,48 +150,61 @@ class Items extends CI_Controller {
$next = $this->db
->where('item_id', $item_id)
->where('status', 'pending')
->where('serial_number IS NULL', NULL, false)
->order_by('id', 'ASC')
->get('item_barcodes')
->row();
$start = max(0, (int)$this->input->get('start'));
$length = min(100, max(10, (int)($this->input->get('length') ?: 50)));
$search = trim((string)$this->input->get('search', true));
$total = (int)$this->db->where('item_id', $item_id)->count_all_results('item_barcodes');
$done = (int)$this->db->where('item_id', $item_id)->where('serial_number IS NOT NULL', NULL, false)->where('serial_number !=', '')->count_all_results('item_barcodes');
$this->db->where('item_id', $item_id);
if ($search !== '') {
$this->db->group_start()->like('barcode', $search)->or_like('serial_number', $search)->group_end();
}
$filtered = (int)$this->db->count_all_results('item_barcodes');
$this->db->where('item_id', $item_id);
if ($search !== '') {
$this->db->group_start()->like('barcode', $search)->or_like('serial_number', $search)->group_end();
}
$list = $this->db
->where('item_id', $item_id)
->order_by('id', 'ASC')
->limit($length, $start)
->get('item_barcodes')
->result();
$total = count($list);
$done = 0;
foreach ($list as $row) {
if (!empty($row->serial_number)) {
$done++;
}
}
echo json_encode([
'status' => true,
'item_status' => $item->status,
'item_sale_price' => (float)$item->harga_jual,
'item_purchase_price' => (float)$item->harga_beli,
'next' => $next,
'list' => $list,
'total' => $total,
'done' => $done,
'filtered' => $filtered,
'start' => $start,
'length' => $length,
'percent' => $total > 0 ? round(($done / $total) * 100) : 0
]);
}
public function print_barcode($item_id)
{
if(!$this->companyItem($item_id)) show_404();
require_once APPPATH.'third_party/fpdf/fpdf.php';
require_once APPPATH.'libraries/phpqrcode/qrlib.php';
$barcodes = $this->db
->where('item_id',$item_id)
->where('status !=', 'sold_out')
->order_by('id','ASC')
->get('item_barcodes')
->result();
$printStatus=$this->input->get('status',true)?:'available';
if(!in_array($printStatus,['available','sold_out','all'],true))$printStatus='available';
$this->db->where('item_id',$item_id);
if($printStatus==='all')$this->db->where_in('status',['available','sold_out']);else$this->db->where('status',$printStatus);
$barcodes=$this->db->order_by('id','ASC')->get('item_barcodes')->result();
if(empty($barcodes)){
show_error('Barcode tidak ditemukan.');
@@ -256,7 +270,8 @@ class Items extends CI_Controller {
$x=$col*$labelWidth;
$y=$line*$labelHeight;
$qrFile=$tempDir.$row->barcode.'.png';
$safeBarcode=preg_replace('/[^A-Za-z0-9_-]/','_',basename((string)$row->barcode));
$qrFile=$tempDir.$safeBarcode.'.png';
if(!file_exists($qrFile)){
QRcode::png(
@@ -315,6 +330,7 @@ class Items extends CI_Controller {
public function save_serial_number()
{
if (strtoupper($this->input->method()) !== 'POST') show_404();
$barcode_id = $this->input->post('barcode_id');
$serial = trim($this->input->post('serial_number'));
@@ -337,13 +353,12 @@ class Items extends CI_Controller {
return;
}
$barcode = $this->db
->where('id',$barcode_id)
->get('item_barcodes')
->row();
$barcode = $this->db->select('item_barcodes.*')->from('item_barcodes')->join('items','items.id=item_barcodes.item_id')->where(array('item_barcodes.id'=>$barcode_id,'items.company_id'=>$this->companyId(),'item_barcodes.status'=>'pending'))->get()->row();
if(!$barcode){echo json_encode(array('status'=>false,'message'=>'Barcode pending tidak ditemukan pada perusahaan aktif.'));return;}
$this->db->where('id',$barcode_id);
$this->db->update('item_barcodes',array(
'serial_number'=>$serial
'serial_number'=>$serial,
'status'=>'pending'
));
// langsung kirim data terbaru
$_GET['item_id']=$barcode->item_id;
@@ -352,6 +367,7 @@ class Items extends CI_Controller {
public function delete_serial_number()
{
if (strtoupper($this->input->method()) !== 'POST') show_404();
$barcode_id = (int)$this->input->post('barcode_id');
if ($barcode_id <= 0) {
@@ -363,10 +379,7 @@ class Items extends CI_Controller {
}
// Ambil data barcode
$barcode = $this->db
->where('id', $barcode_id)
->get('item_barcodes')
->row();
$barcode = $this->db->select('item_barcodes.*')->from('item_barcodes')->join('items','items.id=item_barcodes.item_id')->where(array('item_barcodes.id'=>$barcode_id,'items.company_id'=>$this->companyId(),'item_barcodes.status'=>'pending'))->get()->row();
if (!$barcode) {
echo json_encode([
@@ -376,17 +389,60 @@ class Items extends CI_Controller {
return;
}
if ($barcode->status === 'available' || !empty($barcode->activated_at)) {
echo json_encode(['status'=>false,'message'=>'Serial unit yang sudah diposting tidak dapat dihapus.']);
return;
}
// Hapus Serial Number
$this->db
->where('id', $barcode_id)
->update('item_barcodes', [
'serial_number' => NULL
'serial_number' => NULL,
'status' => 'pending'
]);
$this->db->where('id',$barcode->item_id)->update('items',['status'=>'draft']);
// Kirim ulang data terbaru
$this->load_data_barcode($barcode->item_id);
}
public function post_barcode()
{
return $this->barcodeError('Posting hanya dapat dilakukan dari tabel Barang Pending.');
$barcodeId = (int)$this->input->post('barcode_id');
$salePrice = (float)$this->input->post('sale_price');
$barcode = $this->db->get_where('item_barcodes', ['id'=>$barcodeId])->row();
if (!$barcode) return $this->barcodeError('Barcode tidak ditemukan.');
if ($barcode->status === 'available') return $this->barcodeError('Unit barcode sudah diposting.');
if (trim((string)$barcode->serial_number) === '') return $this->barcodeError('Serial number wajib diisi sebelum unit diposting.');
if ($salePrice <= 0) return $this->barcodeError('Harga jual satuan wajib lebih dari nol.');
$this->db->trans_start();
$this->db->where('id',$barcodeId)->update('item_barcodes',[
'sale_price'=>$salePrice,
'status'=>'available',
'activated_at'=>date('Y-m-d H:i:s'),
'activated_by'=>(int)$this->session->userdata('user_id')
]);
$this->db->where('id',$barcode->item_id)->update('items',[
'harga_jual'=>$salePrice,
'status'=>'active'
]);
$this->db->trans_complete();
if ($this->db->trans_status() === false) return $this->barcodeError('Unit barcode gagal diposting.');
log_activity('items','posting','Posting unit barcode ID '.$barcodeId.' dengan harga jual '.$salePrice,'success');
$this->load_data_barcode($barcode->item_id);
}
private function barcodeError($message)
{
echo json_encode(['status'=>false,'message'=>$message]);
}
public function get_barcode_item()
{
$barcode = trim($this->input->post('barcode'));
@@ -491,6 +547,10 @@ class Items extends CI_Controller {
public function get_data()
{
$status = $this->input->get('s') ?? 'all';
$server = (int)$this->input->get('server') === 1;
$draw = (int)$this->input->get('draw');
$start = max(0,(int)$this->input->get('start'));
$length = min(100,max(10,(int)$this->input->get('length')));
$allowed = ['draft','active','all'];
if (!in_array($status, $allowed)) {
@@ -509,6 +569,13 @@ class Items extends CI_Controller {
items.status,
items.kode_id,
kode.need_serial_number,
COALESCE((SELECT SUM(CASE WHEN ibs.status IN("available","reserved") THEN GREATEST(ibs.qty_sisa-COALESCE(ibs.reserved_qty,0)-COALESCE(ibs.return_reserved_qty,0),0) ELSE 0 END) FROM item_barcodes ibs WHERE ibs.item_id=items.id),items.stok) operational_available,
(SELECT COUNT(*) FROM item_barcodes iba WHERE iba.item_id=items.id AND iba.status IN("available","reserved") AND GREATEST(iba.qty_sisa-COALESCE(iba.reserved_qty,0)-COALESCE(iba.return_reserved_qty,0),0)>0) operational_barcode_count,
COALESCE((SELECT SUM(GREATEST(ibp.qty_sisa-COALESCE(ibp.reserved_qty,0)-COALESCE(ibp.return_reserved_qty,0),0)) FROM item_barcodes ibp WHERE ibp.item_id=items.id AND ibp.status="pending"),items.stok) pending_available,
(SELECT COUNT(*) FROM item_barcodes ibp WHERE ibp.item_id=items.id AND ibp.status="pending" AND GREATEST(ibp.qty_sisa-COALESCE(ibp.reserved_qty,0)-COALESCE(ibp.return_reserved_qty,0),0)>0) pending_barcode_count,
(SELECT COUNT(*) FROM item_barcodes ibc WHERE ibc.item_id=items.id) total_barcodes,
(SELECT COUNT(*) FROM item_barcodes ibc WHERE ibc.item_id=items.id AND NULLIF(ibc.serial_number,"") IS NOT NULL) completed_serials,
(SELECT COUNT(*) FROM item_barcodes ibc WHERE ibc.item_id=items.id AND ibc.status="available") posted_barcodes,
(
SELECT GROUP_CONCAT(DISTINCT w.nama SEPARATOR ", ")
FROM stock_logs sl
@@ -518,21 +585,35 @@ class Items extends CI_Controller {
');
$this->db->join('kode_barang kode', 'items.kode_id = kode.id', 'left');
if ($this->db->field_exists('company_id','items')) {
$this->db->where('items.company_id', (int)$this->session->userdata('company_id'));
}
if ($status !== 'all') {
if ($status === 'draft') {
$this->db->where('EXISTS(SELECT 1 FROM goods_receipt_lines grlp WHERE grlp.item_id=items.id)',null,false)->group_start()->where('items.status','draft')->or_where('EXISTS(SELECT 1 FROM item_barcodes ibp WHERE ibp.item_id=items.id AND ibp.status="pending")',null,false)->group_end();
} elseif ($status !== 'all') {
$this->db->where('items.status', $status);
}
if($server){
$search=$this->input->get('search');
$search=is_array($search)?trim((string)($search['value']??'')):'';
if($search!=='')$this->db->group_start()->like('items.kode_detail',$search)->or_like('items.nama_barang',$search)->group_end();
}
$this->db->from('items');
$filtered=$server?$this->db->count_all_results('',false):0;
// urutan custom
$this->db->order_by('(items.stok = 0)', 'ASC', false);
$this->db->order_by('items.tanggal_pembelian', 'ASC');
$this->db->from('items');
if($server)$this->db->limit($length,$start);
$data = $this->db->get()->result();
$result = [];
$no = 1;
$no = $server?$start+1:1;
foreach($data as $row){
@@ -541,39 +622,11 @@ class Items extends CI_Controller {
$btnDelete = '<button class="btn btn-danger btn-sm btn-delete m-1" data-id="'.htmlspecialchars($row->id, ENT_QUOTES, 'UTF-8').'">Delete</button>';
$btnInsertSn = '<a href="'.base_url('items/item_barcodes/'.$row->id).'" class="btn btn-secondary btn-sm m-1">Barcode</a>';
$btnPrintBarcode = '<button class="btn btn-secondary btn-sm btn-print m-1" data-id="'.htmlspecialchars($row->id, ENT_QUOTES, 'UTF-8').'"><i class="bi bi-printer"></i> Print Barcode</button>';
$btnPost = '<button class="btn btn-success btn-sm btn-updatePosting m-1" data-id="'.htmlspecialchars($row->id, ENT_QUOTES, 'UTF-8').'">Posting</button>';
$btnPost = '<button class="btn btn-success btn-sm btn-updatePosting m-1" data-id="'.htmlspecialchars($row->id, ENT_QUOTES, 'UTF-8').'" data-buy="'.htmlspecialchars($row->harga_beli,ENT_QUOTES,'UTF-8').'" data-sale="'.htmlspecialchars($row->harga_jual,ENT_QUOTES,'UTF-8').'">Posting</button>';
if ($row->need_serial_number == 'true') {
$list = $this->db
->where('item_id', $row->id)
->order_by('id', 'ASC')
->get('item_barcodes')
->result();
$total = count($list);
$done = 0;
foreach ($list as $barcode) {
if (!empty($barcode->serial_number)) {
$done++;
}
}
$total_sn = $total;
$done_sn = $done;
if ($row->status == 'draft') {
if ($total_sn == $done_sn) {
$btnSN = $btnInsertSn . $btnPrintBarcode;
$btnPostview = $btnPost;
} else {
$btnSN = $btnInsertSn ;
$btnPostview = '';
}
} else {
$btnSN = $btnPrintBarcode;
}
$btnSN = $btnInsertSn . ((int)$row->posted_barcodes > 0 ? $btnPrintBarcode : '');
$btnPostview = (int)$row->completed_serials > (int)$row->posted_barcodes ? $btnPost : '';
} else {
@@ -581,35 +634,43 @@ class Items extends CI_Controller {
$btnPostview = $btnPost;
}
if($row->status == 'draft'){
$action = $btnDetail . $btnPostview . $btnDelete . $btnSN;
$isAdmin=$this->session->userdata('role')==='Admin';
$canUpdate=$isAdmin||check_permission('items','can_update');
$canPost=$isAdmin||check_permission('items','can_post');
$canExport=$isAdmin||check_permission('items','can_export');
if($status==='active'){
$action = $btnDetail . ($canExport?$btnPrintBarcode:'');
} elseif($status==='draft'){
$action = $btnDetail . ($canPost?$btnPostview:'') . ($canUpdate?$btnSN:'');
} else {
if($this->session->userdata('role') == 'Admin') {
$btnAdjust = '<button class="btn btn-warning btn-sm btn-adjust m-1" data-id="'.htmlspecialchars($row->id, ENT_QUOTES, 'UTF-8').'">Adjust</button>';
} else {
$btnAdjust = '';
}
$action = $btnDetail . $btnEdit . $btnAdjust . $btnSN;
$action = $btnDetail . ($canExport?$btnPrintBarcode:'');
}
$barcodeCount = $status === 'active' ? (int)$row->operational_barcode_count : (int)$row->pending_barcode_count;
$stockAvailable = $status === 'active' ? (float)$row->operational_available : (float)$row->pending_available;
$barcodeStock = '<span title="Jumlah barcode / jumlah stok"><strong>'.number_format($barcodeCount, 0, ',', '.').'</strong> / '.number_format($stockAvailable, 0, ',', '.').'</span>';
$result[] = [
$no++,
$row->tanggal_pembelian,
$row->kode_detail,
$row->nama_barang,
$row->gudang ?? '-',
$row->stok,
number_format($row->harga_beli ?? 0, 2),
number_format($row->harga_jual ?? 0, 2),
$barcodeStock,
number_format($row->harga_beli ?? 0, 2, ',', '.'),
$status==='draft'?'<input type="text" class="form-control form-control-sm format-rupiah pending-sale-price" data-id="'.(int)$row->id.'" data-buy="'.htmlspecialchars($row->harga_beli,ENT_QUOTES,'UTF-8').'" value="'.($row->harga_jual>0?number_format($row->harga_jual,2,',','.'):'').'" placeholder="Minimal '.number_format($row->harga_beli,2,',','.').'" inputmode="decimal">':number_format($row->harga_jual??0,2,',','.'),
$row->created_at,
$action
];
}
echo json_encode(["data"=>$result]);
$payload=array("data"=>$result);
if($server){$payload['draw']=$draw;$payload['recordsTotal']=$filtered;$payload['recordsFiltered']=$filtered;}
echo json_encode($payload);
}
// =========================
@@ -627,7 +688,17 @@ class Items extends CI_Controller {
// =========================
public function detail($id)
{
$item = $this->db->get_where('items',['id'=>$id])->row();
$item = $this->companyItem($id);
if (!$item) {
return json_response(false, 'Barang tidak ditemukan.', array(), 404);
}
if ($item->status === 'active') {
$availability = $this->db->query("SELECT COUNT(*) barcode_count,COALESCE(SUM(CASE WHEN status IN('available','reserved') THEN GREATEST(qty_sisa-COALESCE(reserved_qty,0)-COALESCE(return_reserved_qty,0),0) ELSE 0 END),0) available_stock,COALESCE(SUM(COALESCE(reserved_qty,0)+COALESCE(return_reserved_qty,0)),0) reserved_stock FROM item_barcodes WHERE item_id=?", array((int)$item->id))->row();
$item->physical_stock = (float)$item->stok;
$item->available_stock = $availability && (int)$availability->barcode_count > 0 ? (float)$availability->available_stock : (float)$item->stok;
$item->reserved_stock = $availability ? (float)$availability->reserved_stock : 0;
}
$logs = $this->db
->select('stock_logs.*, warehouses.nama as gudang')
@@ -637,16 +708,26 @@ class Items extends CI_Controller {
->get('stock_logs')
->result();
echo json_encode([
'item'=>$item,
'logs'=>$logs
]);
return json_response(true, 'Detail barang berhasil dimuat.', array(
'item' => $item,
'logs' => $logs
));
}
public function stock_log_pdf($id)
{
$item=$this->companyItem($id);
if(!$item)show_404();
$logs=$this->db->select('sl.*,w.nama gudang')->from('stock_logs sl')->join('warehouses w','w.id=sl.warehouse_id','left')->where('sl.item_id',(int)$id)->order_by('sl.id','ASC')->get()->result();
require_once APPPATH.'third_party/fpdf/fpdf.php';
$pdf=new FPDF('L','mm','A4');$pdf->SetMargins(12,12,12);$pdf->AddPage();$pdf->SetFont('Arial','B',15);$pdf->Cell(0,8,'STOCK LOG BARANG',0,1,'C');$pdf->SetFont('Arial','',9);$pdf->Cell(0,5,$item->kode_detail.' - '.$item->nama_barang,0,1,'C');$pdf->Ln(5);
$widths=[12,34,42,20,20,105,35];foreach(['No','Tanggal','Gudang','Qty','Tipe','Keterangan','Referensi']as$i=>$label){$pdf->SetFont('Arial','B',8);$pdf->Cell($widths[$i],7,$label,1,0,'C');}$pdf->Ln();$balance=0;$pdf->SetFont('Arial','',8);foreach($logs as$i=>$log){$signed=$log->tipe==='masuk'?(float)$log->qty:-(float)$log->qty;$balance+=$signed;$pdf->Cell($widths[0],7,$i+1,1,0,'C');$pdf->Cell($widths[1],7,substr($log->created_at,0,19),1);$pdf->Cell($widths[2],7,substr($log->gudang?:'-',0,24),1);$pdf->Cell($widths[3],7,number_format(abs($log->qty),0,',','.'),1,0,'R');$pdf->Cell($widths[4],7,strtoupper($log->tipe),1,0,'C');$pdf->Cell($widths[5],7,substr($log->keterangan?:'-',0,70),1);$pdf->Cell($widths[6],7,substr(($log->ref_type?:'-').' #'.($log->ref_id?:'-'),0,22),1,1);}$pdf->SetFont('Arial','B',9);$pdf->Cell(108,8,'SALDO STOK',1,0,'R');$pdf->Cell(20,8,number_format($balance,0,',','.'),1,0,'R');$pdf->Cell(140,8,'',1,1);$pdf->Output('I','Stock-Log-'.$item->kode_detail.'.pdf');
}
public function get_accounts()
{
$data = $this->db
->where_in('kode_akun', [101,102,201,122,125,105])
->where_in('kode_akun', ['1010','1020','2010','1220','1250','1050'])
->order_by('kode_akun','ASC')
->get('accounts')
->result();
@@ -670,6 +751,7 @@ class Items extends CI_Controller {
// =========================
public function save()
{
return $this->legacyMutationDisabled('Penambahan stok langsung dinonaktifkan. Gunakan Saldo Awal atau Barang Masuk pada Operasional Stok.');
$nama_barang = $this->input->post('nama_barang');
$qty = (int)$this->input->post('qty');
$harga_beli = (float)$this->input->post('harga_beli');
@@ -682,7 +764,7 @@ class Items extends CI_Controller {
$status = $this->input->post('status');
// ================= VALIDASI =================
if(!$nama_barang || $qty <= 0 || $harga_beli <= 0 || !$warehouse_id || !$account_kas){
if(!$nama_barang || $qty <= 0 || $harga_beli <= 0 || !$warehouse_id || ($status !== 'draft' && !$account_kas)){
echo json_encode([
'status'=>false,
'message'=>'Data tidak lengkap / tidak valid',
@@ -734,6 +816,16 @@ class Items extends CI_Controller {
$total = $qty * $harga_beli;
// Barang pending hanya master/rencana pembelian. Belum membentuk stok,
// barcode, pergerakan barang, maupun jurnal sampai diterima melalui PO.
if ($status === 'draft') {
$this->db->insert('items',array('stok'=>0,'planned_qty'=>$qty,'planned_warehouse_id'=>$warehouse_id,'planned_tracking_qty'=>$tracking_qty,'kode_detail'=>$kode_detail,'kode_id'=>$kode_id,'nama_barang'=>$nama_barang,'harga_beli'=>$harga_beli,'harga_jual'=>$harga_jual,'tanggal_pembelian'=>$tanggal_beli,'status'=>'draft'));
$itemId=(int)$this->db->insert_id();
log_activity('items','create','Membuat master barang pending: '.$nama_barang,'success');
echo json_encode(array('status'=>true,'message'=>'Barang pending dibuat. Stok baru aktif setelah penerimaan PO.','item_id'=>$itemId));
return;
}
$this->db->trans_start();
// ================= INSERT ITEM =================
@@ -854,6 +946,7 @@ class Items extends CI_Controller {
// =========================
public function update()
{
return $this->legacyMutationDisabled('Barang aktif tidak dapat diubah melalui form lama. Gunakan master Jenis/Kode Barang atau dokumen persediaan yang sesuai.');
$id = $this->input->post('id');
$nama_barang = $this->input->post('nama_barang');
$harga_baru = (float)$this->input->post('harga_beli');
@@ -946,6 +1039,8 @@ class Items extends CI_Controller {
public function delete($id)
{
echo json_encode(array('status'=>false,'message'=>'Barang tidak dapat dihapus. Gunakan status nonaktif pada master bila sudah tidak digunakan.'));
return;
// cek item ada
$item = $this->db->get_where('items',['id'=>$id])->row();
@@ -954,10 +1049,26 @@ class Items extends CI_Controller {
return;
}
// Master/item yang telah menjadi bagian dokumen pembelian tidak boleh
// dihapus. Menghapusnya akan meninggalkan item_id yatim pada baris PO
// dan membuat penerimaan gagal saat menulis stock_logs.
if ($this->db->table_exists('purchase_order_lines')) {
$cekPembelian = $this->db->where('item_id', (int) $id)
->count_all_results('purchase_order_lines');
if ($cekPembelian > 0) {
echo json_encode([
'status'=>false,
'message'=>'Barang tidak dapat dihapus karena sudah digunakan pada Purchase Order. Nonaktifkan barang bila tidak akan digunakan lagi.'
]);
return;
}
}
// cek ada transaksi stock
$cekStock = $this->db->where('item_id',$id)->count_all_results('stock_logs');
if($cekStock > 1){
if($cekStock > 0){
echo json_encode([
'status'=>false,
'message'=>'Tidak bisa dihapus, sudah ada transaksi stok'
@@ -1002,6 +1113,7 @@ class Items extends CI_Controller {
public function posting()
{
if (strtoupper($this->input->method()) !== 'POST') show_404();
$id = $this->input->post('id');
if(!$id){
@@ -1013,7 +1125,7 @@ class Items extends CI_Controller {
}
// cek item ada
$item = $this->db->get_where('items', ['id' => $id])->row();
$item = $this->db->get_where('items', ['id' => $id, 'company_id'=>(int)$this->session->userdata('company_id')])->row();
if(!$item){
echo json_encode([
@@ -1023,8 +1135,21 @@ class Items extends CI_Controller {
return;
}
$catalog = $this->db->get_where('kode_barang',['id'=>$item->kode_id])->row();
$serialRequired=$catalog&&strtolower((string)$catalog->need_serial_number)==='true';
$submittedSalePrice=(float)$this->input->post('harga_jual');
if($submittedSalePrice>0)$item->harga_jual=$submittedSalePrice;
if ((float)$item->harga_jual <= 0) {
echo json_encode(['status'=>false,'message'=>'Harga jual wajib diisi sebelum barang diposting.']);
return;
}
if ((float)$item->harga_jual < (float)$item->harga_beli) {
echo json_encode(['status'=>false,'message'=>'Harga jual tidak boleh lebih rendah dari harga beli Rp '.number_format($item->harga_beli,2,',','.')]);
return;
}
// cegah double posting
if($item->status == 'active'){
if(!$serialRequired&&$item->status == 'active'){
echo json_encode([
'status' => false,
'message' => 'Barang sudah diterima sebelumnya'
@@ -1034,10 +1159,24 @@ class Items extends CI_Controller {
$this->db->trans_start();
// update status saja
if($serialRequired){
$ready=$this->db->where('item_id',$id)->where('status','pending')->where('serial_number IS NOT NULL',null,false)->where('serial_number !=','')->count_all_results('item_barcodes');
if($ready<=0){$this->db->trans_complete();echo json_encode(['status'=>false,'message'=>'Belum ada serial number yang siap diposting.']);return;}
$this->db->where('item_id',$id)->where('status','pending')->where('serial_number IS NOT NULL',null,false)->where('serial_number !=','')->update('item_barcodes',[
'sale_price'=>$item->harga_jual,'status'=>'available','activated_at'=>date('Y-m-d H:i:s'),'activated_by'=>(int)$this->session->userdata('user_id')
]);
$posted=$this->db->affected_rows();
}else{
$this->db->where('item_id',$id)->where('status','pending')->update('item_barcodes',[
'sale_price'=>$item->harga_jual,'status'=>'available','activated_at'=>date('Y-m-d H:i:s'),'activated_by'=>(int)$this->session->userdata('user_id')
]);
$posted=$this->db->affected_rows();
}
$notReady=$this->db->where('item_id',$id)->where('status','pending')->count_all_results('item_barcodes');
$this->db->where('id', $id);
$this->db->update('items', [
'status' => 'active'
'status' => 'active',
'harga_jual' => $item->harga_jual
]);
$this->db->trans_complete();
@@ -1061,7 +1200,9 @@ class Items extends CI_Controller {
echo json_encode([
'status' => true,
'message' => 'Barang sudah diterima dan stok siap digunakan'
'message' => $serialRequired?($posted.' unit berhasil diposting dan '.$notReady.' unit masih belum siap.'):('Barang berhasil diposting. '.$posted.' barcode siap digunakan.'),
'posted_count'=>$posted,
'pending_count'=>$notReady
]);
}
}
@@ -1071,6 +1212,7 @@ class Items extends CI_Controller {
// =========================
public function keluarkan()
{
return $this->legacyMutationDisabled('Pengeluaran langsung dinonaktifkan. Buat dokumen Barang Keluar pada Operasional Stok agar ledger, barcode, dan jurnal tetap konsisten.');
$account_biaya = (int)$this->input->post('account_biaya');
$tanggal_keluar = $this->input->post('tanggal_keluar');
$keterangan_keluar = $this->input->post('keterangan_keluar');
@@ -1307,6 +1449,7 @@ class Items extends CI_Controller {
public function adjust()
{
return $this->legacyMutationDisabled('Adjustment langsung dinonaktifkan. Gunakan dokumen Penyesuaian dengan workflow approval pada Operasional Stok.');
$item_id = $this->input->post('item_id');
$qty = (int)$this->input->post('qty');
$warehouse_id = $this->input->post('warehouse_id');
@@ -1412,15 +1555,16 @@ class Items extends CI_Controller {
items.harga_jual,
items.kode_detail,
COALESCE(SUM(
stock_logs.qty * IF(stock_logs.tipe="masuk",1,-1)
inventory_ledger.qty * IF(inventory_ledger.direction="in",1,-1)
),0) as stok
');
$this->db->where('status', 'active');
$this->db->where('items.status', 'active');
$this->db->where('items.company_id', $this->companyId());
$this->db->from('items');
$this->db->join('stock_logs',
'stock_logs.item_id = items.id
AND stock_logs.warehouse_id = '.$this->db->escape($warehouse_id),
$this->db->join('inventory_ledger',
'inventory_ledger.item_id = items.id
AND inventory_ledger.warehouse_id = '.$this->db->escape($warehouse_id),
'left');
$this->db->group_by('items.id');
@@ -1436,6 +1580,7 @@ class Items extends CI_Controller {
public function update_barcode_item()
{
return $this->legacyMutationDisabled('Pembuatan barcode massal tanpa dokumen sumber dinonaktifkan. Barcode dibuat dari penerimaan atau dokumen persediaan.');
$items = $this->db
->select("
i.id,
@@ -1574,4 +1719,9 @@ class Items extends CI_Controller {
'message' => 'Barcode berhasil diperbarui.'
]);
}
}
private function legacyMutationDisabled($message)
{
return $this->output->set_status_header(422)->set_content_type('application/json')->set_output(json_encode(array('status'=>false,'message'=>$message)));
}
}
@@ -0,0 +1,8 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Journaldescriptionrepair extends CI_Controller
{
public function __construct(){parent::__construct();if(!is_cli())show_404();$this->load->library(array('JournalDescriptionService','AuditService'));}
public function run(){if(strtolower((string)app_env('APP_ENV',ENVIRONMENT))==='production'){echo "Ditolak: backfill ini khusus Development.\n";return;}$rows=$this->db->where("COALESCE(ref_type,'') NOT IN ('','manual_journal')",null,false)->not_like('keterangan','[AUTO]','after')->order_by('id')->get('journals')->result();$count=0;$this->db->trans_begin();try{foreach($rows as$j){$lines=$this->db->select('account_id,debit,kredit')->where('journal_id',$j->id)->get('journal_details')->result_array();if(!$lines)continue;$new=$this->journaldescriptionservice->build((array)$j,$lines);$this->db->where('id',$j->id)->update('journals',array('keterangan'=>$new));$this->auditservice->record('development_repair','journal',$j->id,'enrich_automatic_description',array('keterangan'=>$j->keterangan),array('keterangan'=>$new),null);$count++;}if(!$this->db->trans_status())throw new RuntimeException('Backfill gagal.');$this->db->trans_commit();echo "Selesai: {$count} keterangan jurnal otomatis diperkaya.\n";}catch(Throwable$e){$this->db->trans_rollback();echo 'Gagal: '.$e->getMessage()."\n";exit(1);}}
public function verify(){$missing=$this->db->where("COALESCE(ref_type,'') NOT IN ('','manual_journal')",null,false)->not_like('keterangan','[AUTO]','after')->count_all_results('journals');echo($missing?'[FAIL] ':'[OK] ').'automatic_descriptions_without_standard='.$missing."\n";if($missing)exit(1);}
}
+179 -73
View File
@@ -1,12 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Jurnal extends CI_Controller {
class Jurnal extends MY_Controller {
public function __construct()
{
parent::__construct();
$this->load->model('DynamicModel', 'dm');
$this->load->library('JournalService');
$this->load->library('NumberingService');
$this->load->library('JournalWorkflowService');
if (!$this->session->userdata('logged_in')) {
redirect('auth');
@@ -35,12 +38,19 @@ class Jurnal extends CI_Controller {
$start = $_POST['start'];
$length = $_POST['length'];
$search = $_POST['search']['value'];
$status = (string) $this->input->post('status', true);
$allowedStatuses = array('draft', 'submitted', 'approved', 'posted', 'rejected', 'reversed');
if (!in_array($status, $allowedStatuses, true)) $status = '';
// ================= TOTAL ALL
$totalAll = $this->db->count_all('journals');
$totalAll = $this->db->where('company_id',$this->companycontext->id())->count_all_results('journals');
// ================= QUERY FILTERED (HITUNG)
$this->db->from('journals');
$this->db->from('journals')->where('company_id',$this->companycontext->id());
if ($status !== '') {
$this->db->where('status', $status);
}
if(!empty($search)){
$this->db->group_start();
@@ -58,11 +68,21 @@ class Jurnal extends CI_Controller {
journals.tanggal,
journals.no_ref,
journals.keterangan,
journals.status,
journals.is_reversed,
journals.ref_type,
journals.created_by,
journals.rejection_reason,
COALESCE(SUM(journal_details.debit),0) as total_debit,
COALESCE(SUM(journal_details.kredit),0) as total_kredit
');
$this->db->from('journals');
$this->db->join('journal_details','journal_details.journal_id = journals.id','LEFT');
$this->db->where('journals.company_id',$this->companycontext->id());
if ($status !== '') {
$this->db->where('journals.status', $status);
}
if(!empty($search)){
$this->db->group_start();
@@ -86,19 +106,27 @@ class Jurnal extends CI_Controller {
foreach ($query as $row) {
$no++;
if($this->session->userdata('role') != 'Admin'){
$aksi = '
<button class="btn btn-sm btn-info btn-detail" data-id="'.$row->id.'">Detail</button>
';
$isAdmin = is_master_admin_user() || $this->session->userdata('role') === 'Admin';
$isOwner = (int)$row->created_by === (int)$this->session->userdata('user_id');
$aksi = '<button class="btn btn-sm btn-info btn-detail" data-id="'.$row->id.'">Detail</button> ';
if ($row->status === 'draft') {
$aksi .= '<button class="btn btn-sm btn-primary btn-submit" data-id="'.$row->id.'">Submit</button> ';
if ($isAdmin) $aksi .= '<button class="btn btn-sm btn-danger btn-delete" data-id="'.$row->id.'">Hapus</button> ';
}
else {
$aksi = '
<button class="btn btn-sm btn-info btn-detail" data-id="'.$row->id.'">Detail</button>
<button class="btn btn-sm btn-danger btn-delete" data-id="'.$row->id.'">Delete</button>
';
if ($row->status === 'rejected' && ($isOwner || $isAdmin) && in_array((string)$row->ref_type, array('', 'manual_journal'), true)) {
$aksi .= '<button class="btn btn-sm btn-warning btn-edit-rejected" data-id="'.$row->id.'">Edit & Koreksi</button> ';
if ($isAdmin) $aksi .= '<button class="btn btn-sm btn-danger btn-delete" data-id="'.$row->id.'">Hapus</button> ';
}
if ($isAdmin && $row->status === 'submitted') {
$aksi .= '<button class="btn btn-sm btn-success btn-approve" data-id="'.$row->id.'">Approve</button> ';
$aksi .= '<button class="btn btn-sm btn-outline-danger btn-reject" data-id="'.$row->id.'">Reject</button> ';
}
if ($isAdmin && $row->status === 'posted') {
$aksi .= '<button class="btn btn-sm btn-outline-warning btn-reverse" data-id="'.$row->id.'">Reverse</button>';
}
$statusClass = array('draft'=>'secondary','submitted'=>'info','approved'=>'primary','posted'=>'success','rejected'=>'danger','reversed'=>'warning');
$badge = '<span class="badge bg-'.(isset($statusClass[$row->status]) ? $statusClass[$row->status] : 'secondary').'">'.htmlspecialchars(strtoupper($row->status), ENT_QUOTES, 'UTF-8').'</span>';
$data[] = [
$no,
@@ -107,6 +135,7 @@ class Jurnal extends CI_Controller {
$row->keterangan,
number_format($row->total_debit,0,',','.'),
number_format($row->total_kredit,0,',','.'),
$badge,
$aksi
];
}
@@ -124,7 +153,7 @@ class Jurnal extends CI_Controller {
// =========================
public function get_accounts()
{
$data = $this->db->order_by('kode_akun','ASC')->get('accounts')->result();
$data = $this->db->where(['company_id'=>$this->companycontext->id(),'is_active'=>1,'allow_posting'=>1])->order_by('kode_akun','ASC')->get('accounts')->result();
echo json_encode($data);
}
@@ -133,68 +162,71 @@ class Jurnal extends CI_Controller {
// =========================
public function save()
{
$tanggal = $this->input->post('tanggal');
$no_ref = 'JR-' . date('YmdHis');
$keterangan = $this->input->post('keterangan');
$tanggal = (string) $this->input->post('tanggal', true);
$keterangan = trim((string) $this->input->post('keterangan', true));
$accounts = (array) $this->input->post('account_id');
$debit = (array) $this->input->post('debit');
$kredit = (array) $this->input->post('kredit');
$accounts = $this->input->post('account_id');
$debit = $this->input->post('debit');
$kredit = $this->input->post('kredit');
try {
$noRef = $this->numberingservice->next('journal', $tanggal);
$lines = array();
foreach ($accounts as $index => $accountId) {
$lineDebit = isset($debit[$index]) ? $this->parseNominal($debit[$index]) : 0;
$lineCredit = isset($kredit[$index]) ? $this->parseNominal($kredit[$index]) : 0;
if ($lineDebit <= 0 && $lineCredit <= 0) continue;
$lines[] = array(
'account_id' => $accountId,
'debit' => $lineDebit,
'kredit' => $lineCredit
);
}
if (array_sum($debit) != array_sum($kredit)) {
echo json_encode([
'status'=>false,
'message'=>'Debit & Kredit tidak balance!'
]);
return;
}
$journalId = $this->journalservice->create(array(
'tanggal' => $tanggal,
'no_ref' => $noRef,
'keterangan' => $keterangan,
'ref_type' => 'manual_journal',
'created_by' => (int) $this->session->userdata('user_id')
), $lines);
$this->db->trans_start();
$this->db->insert('journals', [
'tanggal' => $tanggal,
'no_ref' => $no_ref,
'keterangan' => $keterangan,
'created_by' => $this->session->userdata('user_id')
]);
$journal_id = $this->db->insert_id();
for ($i=0; $i<count($accounts); $i++) {
if ($accounts[$i] == '') continue;
$this->db->insert('journal_details', [
'journal_id' => $journal_id,
'account_id' => $accounts[$i],
'debit' => $debit[$i] ?: 0,
'kredit' => $kredit[$i] ?: 0
]);
log_activity('Jurnal', 'create', 'Membuat jurnal No Ref: ' . $noRef, 'success');
return json_response(true, 'Jurnal berhasil disimpan.', array(
'journal_id' => $journalId,
'no_ref' => $noRef
));
} catch (Throwable $exception) {
return business_exception_response($exception);
}
}
$this->db->trans_complete();
if ($this->db->trans_status() === FALSE) {
echo json_encode([
'status'=>false,
'message'=>'Gagal simpan jurnal'
]);
return;
public function update_rejected($id)
{
if (strtoupper($this->input->method()) !== 'POST') return json_response(false, 'Metode request tidak diizinkan.', array(), 405);
try {
$accounts=(array)$this->input->post('account_id');$debit=(array)$this->input->post('debit');$kredit=(array)$this->input->post('kredit');$lines=array();
foreach($accounts as$index=>$accountId){$d=isset($debit[$index])?$this->parseNominal($debit[$index]):0;$k=isset($kredit[$index])?$this->parseNominal($kredit[$index]):0;if($d<=0&&$k<=0)continue;$lines[]=array('account_id'=>(int)$accountId,'debit'=>$d,'kredit'=>$k);}
$journal=$this->journalservice->reviseRejected((int)$id,array('tanggal'=>(string)$this->input->post('tanggal',true),'keterangan'=>trim((string)$this->input->post('keterangan',true))),$lines,(int)$this->session->userdata('user_id'));
log_activity('Jurnal','revise_rejected','Mengoreksi jurnal ditolak '.$journal->no_ref.' menjadi Draft.','success');
return json_response(true,'Koreksi jurnal berhasil disimpan sebagai Draft dan dapat diajukan kembali.',array('journal_id'=>$journal->id));
} catch (Throwable $exception) { return business_exception_response($exception); }
}
/** Menerima nominal raw maupun berformat 20,000 / 20.000 / 20.000,50. */
private function parseNominal($value)
{
$value = trim(str_ireplace(array('Rp', ' '), '', (string) $value));
if ($value === '') return 0.0;
$comma = strrpos($value, ','); $dot = strrpos($value, '.');
if ($comma !== false && $dot !== false) {
$value = $comma > $dot ? str_replace(',', '.', str_replace('.', '', $value)) : str_replace(',', '', $value);
} elseif ($comma !== false) {
$decimal = strlen($value) - $comma - 1;
$value = $decimal > 0 && $decimal <= 2 ? str_replace(',', '.', $value) : str_replace(',', '', $value);
} elseif ($dot !== false && strlen($value) - $dot - 1 === 3) {
$value = str_replace('.', '', $value);
}
// ================= LOG ACTIVITY =================
log_activity(
'Jurnal',
'create',
'Membuat jurnal No Ref: '.$no_ref,
'success'
);
echo json_encode([
'status'=>true,
'message'=>'Jurnal berhasil disimpan'
]);
return is_numeric($value) ? round((float) $value, 2) : 0.0;
}
// =========================
@@ -202,7 +234,8 @@ class Jurnal extends CI_Controller {
// =========================
public function detail($id)
{
$header = $this->db->get_where('journals',['id'=>$id])->row();
$header = $this->db->get_where('journals',['id'=>$id,'company_id'=>$this->companycontext->id()])->row();
if (!$header) return json_response(false, 'Jurnal tidak ditemukan.', array(), 404);
$detail = $this->db
->select('accounts.kode_akun, accounts.nama_akun, journal_details.*')
@@ -220,10 +253,19 @@ class Jurnal extends CI_Controller {
->get('users')
->row();
$history = $this->db
->select('journal_status_histories.*, users.nama changed_by_name')
->from('journal_status_histories')
->join('users', 'users.id = journal_status_histories.changed_by', 'left')
->where('journal_id', $id)
->order_by('journal_status_histories.id', 'ASC')
->get()->result();
echo json_encode([
'header'=>$header,
'detail'=>$detail,
'user' =>$user
'user' =>$user,
'history'=>$history
]);
}
@@ -232,6 +274,10 @@ class Jurnal extends CI_Controller {
// =========================
public function delete($id)
{
if (strtoupper($this->input->method()) !== 'POST') {
return json_response(false, 'Metode request tidak diizinkan.', array(), 405);
}
$this->requireAdmin();
$journal = $this->db->get_where('journals', ['id' => $id])->row();
// VALIDASI
@@ -243,6 +289,10 @@ class Jurnal extends CI_Controller {
return;
}
if (!in_array($journal->status, array('draft', 'rejected'), true)) {
return json_response(false, 'Hanya jurnal draft atau rejected yang dapat dihapus.', array(), 422);
}
$this->db->trans_start();
// =========================
@@ -301,6 +351,62 @@ class Jurnal extends CI_Controller {
]);
}
public function submit($id)
{
return $this->workflowAction(function () use ($id) {
return $this->journalworkflowservice->submit($id, $this->session->userdata('user_id'));
}, 'Jurnal berhasil diajukan.');
}
public function approve($id)
{
$this->requireAdmin();
return $this->workflowAction(function () use ($id) {
return $this->journalworkflowservice->approve($id, $this->session->userdata('user_id'));
}, 'Approval jurnal berhasil diproses. Setelah approval final, jurnal otomatis diposting.');
}
public function post($id)
{
$this->requireAdmin();
return $this->workflowAction(function () use ($id) {
return $this->journalworkflowservice->post($id, $this->session->userdata('user_id'));
}, 'Jurnal berhasil diposting.');
}
public function reject($id)
{
$this->requireAdmin();
$reason = $this->input->post('reason', true);
return $this->workflowAction(function () use ($id, $reason) {
return $this->journalworkflowservice->reject($id, $this->session->userdata('user_id'), $reason);
}, 'Jurnal berhasil ditolak.');
}
public function reverse($id)
{
$this->requireAdmin();
$date = $this->input->post('date', true);
$reason = $this->input->post('reason', true);
return $this->workflowAction(function () use ($id, $date, $reason) {
return $this->journalworkflowservice->reverse($id, $date, $this->session->userdata('user_id'), $reason);
}, 'Reversal jurnal berhasil dibuat.');
}
private function workflowAction(callable $callback, $message)
{
if (strtoupper($this->input->method()) !== 'POST') {
return json_response(false, 'Metode request tidak diizinkan.', array(), 405);
}
try {
$result = $callback();
log_activity('Jurnal', 'workflow', $message, 'success');
return json_response(true, $message, array('result' => $result));
} catch (Throwable $exception) {
return business_exception_response($exception);
}
}
public function get_base_jurnal()
{
echo json_encode(
@@ -330,4 +436,4 @@ class Jurnal extends CI_Controller {
'stok' => $stok
]);
}
}
}
+32 -42
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Kodebarang extends CI_Controller {
class Kodebarang extends MY_Controller {
public function __construct()
{
@@ -25,7 +25,7 @@ class Kodebarang extends CI_Controller {
// =========================
public function get_data()
{
$data = $this->db->order_by('id', 'DESC')->get('kode_barang')->result();
$data = $this->db->where('company_id',(int)$this->session->userdata('company_id'))->order_by('id', 'DESC')->get('kode_barang')->result();
$result = [];
$no = 1;
@@ -38,11 +38,10 @@ class Kodebarang extends CI_Controller {
(int)$row->limit_stock,
strtoupper($row->tracking_type ?? 'UNIT'),
strtolower($row->need_serial_number ?? 'false') === 'true' ? 'Ya' : 'Tidak',
(int)($row->allocate_purchase_cost ?? 0) === 1 ? 'Ya' : 'Tidak',
$row->unit ?? 'PCS',
'
<button class="btn btn-sm btn-secondary btn-editkode" data-id="'.$row->id.'">Edit</button>
<button class="btn btn-sm btn-danger btn-delete" data-id="'.$row->id.'">Delete</button>
'
(int)$row->is_active===1?'<span class="status-chip status-approved">Aktif</span>':'<span class="status-chip status-rejected">Nonaktif</span>',
($this->session->userdata('role')==='Admin'||check_permission('items','can_update'))?'<button class="btn btn-sm btn-outline-primary btn-editkode" data-id="'.$row->id.'"><i class="bi bi-pencil"></i> Ubah</button>':'-'
];
}
@@ -55,7 +54,7 @@ class Kodebarang extends CI_Controller {
public function detail($id)
{
echo json_encode(
$this->db->get_where('kode_barang',['id'=>$id])->row()
$this->db->get_where('kode_barang',['id'=>$id,'company_id'=>(int)$this->session->userdata('company_id')])->row()
);
}
@@ -69,7 +68,9 @@ class Kodebarang extends CI_Controller {
$limit_stock = (int)$this->input->post('limit_stock');
$tracking_type = strtoupper(trim($this->input->post('tracking_type')));
$need_serial_number = strtolower(trim($this->input->post('need_serial_number')));
$allocate_purchase_cost = $this->input->post('allocate_purchase_cost') ? 1 : 0;
$unit = trim($this->input->post('unit'));
$is_active = $this->input->post('is_active') ? 1 : 0;
if(!$nama || !$kode){
echo json_encode(['status'=>false,'message'=>'Kode dan nama wajib diisi']);
@@ -91,7 +92,7 @@ class Kodebarang extends CI_Controller {
return;
}
$exists = $this->db->get_where('kode_barang', ['kode_barang' => $kode])->num_rows();
$exists = $this->db->get_where('kode_barang', ['kode_barang' => $kode,'company_id'=>(int)$this->session->userdata('company_id')])->num_rows();
if($exists > 0){
echo json_encode(['status'=>false,'message'=>'Kode barang sudah digunakan']);
return;
@@ -99,12 +100,15 @@ class Kodebarang extends CI_Controller {
$this->db->insert('kode_barang', [
'kode_barang' => $kode,
'company_id' => (int)$this->session->userdata('company_id'),
'nama' => $nama,
'created_at' => date('Y-m-d H:i:s'),
'limit_stock' => $limit_stock,
'tracking_type' => $tracking_type,
'need_serial_number' => $need_serial_number,
'unit' => $unit
'allocate_purchase_cost' => $allocate_purchase_cost,
'unit' => $unit,
'is_active' => $is_active
]);
log_activity(
@@ -128,7 +132,9 @@ class Kodebarang extends CI_Controller {
$limit_stock = (int)$this->input->post('limit_stock');
$tracking_type = strtoupper(trim($this->input->post('tracking_type')));
$need_serial_number = strtolower(trim($this->input->post('need_serial_number')));
$allocate_purchase_cost = $this->input->post('allocate_purchase_cost') ? 1 : 0;
$unit = trim($this->input->post('unit'));
$is_active = $this->input->post('is_active') ? 1 : 0;
if(!$id || !$nama || !$kode){
echo json_encode(['status'=>false,'message'=>'ID, kode dan nama wajib diisi']);
@@ -152,6 +158,7 @@ class Kodebarang extends CI_Controller {
$exists = $this->db
->where('kode_barang', $kode)
->where('company_id',(int)$this->session->userdata('company_id'))
->where('id !=', $id)
->get('kode_barang')
->num_rows();
@@ -161,13 +168,15 @@ class Kodebarang extends CI_Controller {
return;
}
$this->db->where('id',$id)->update('kode_barang', [
$this->db->where(array('id'=>$id,'company_id'=>(int)$this->session->userdata('company_id')))->update('kode_barang', [
'kode_barang' => $kode,
'nama' => $nama,
'limit_stock' => $limit_stock,
'tracking_type' => $tracking_type,
'need_serial_number' => $need_serial_number,
'unit' => $unit
'allocate_purchase_cost' => $allocate_purchase_cost,
'unit' => $unit,
'is_active' => $is_active
]);
log_activity(
@@ -185,44 +194,25 @@ class Kodebarang extends CI_Controller {
// =========================
public function delete($id)
{
$row = $this->db->get_where('kode_barang', ['id'=>$id])->row();
if(!$row){
echo json_encode([
'status'=>false,
'message'=>'Data kode barang tidak ditemukan'
]);
return;
if (strtoupper($this->input->method()) !== 'POST') {
$this->output->set_status_header(405);
} else {
$this->output->set_status_header(409);
}
// cek relasi ke items (mengacu ke kode_id)
$cek = $this->db->get_where('items',['kode_id'=>$id])->num_rows();
if($cek > 0){
echo json_encode([
'status'=>false,
'message'=>'Kode barang tidak bisa dihapus karena masih dipakai item'
]);
return;
}
$this->db->delete('kode_barang',['id'=>$id]);
log_activity(
'kode_barang',
'delete',
'Hapus kode barang ID: ' . $id . ' (' . $row->kode_barang . ' - ' . $row->nama . ')',
'success'
);
echo json_encode(['status'=>true,'message'=>'Kode barang berhasil dihapus']);
return $this->output
->set_content_type('application/json')
->set_output(json_encode(array(
'status' => false,
'message' => 'Jenis/Kode Barang tidak dapat dihapus. Nonaktifkan melalui tombol Ubah.'
)));
}
public function list()
{
echo json_encode(
$this->db->get('kode_barang')->result()
$this->db->where(array('company_id'=>(int)$this->session->userdata('company_id'),'is_active'=>1))->get('kode_barang')->result()
);
}
}
}
+3 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Labarugi extends CI_Controller {
class Labarugi extends MY_Admin_Controller {
public function __construct()
{
@@ -43,6 +43,7 @@ class Labarugi extends CI_Controller {
// ================= JOIN
$this->db->join('journal_details jd','a.id = jd.account_id','LEFT');
$this->db->join('journals j','j.id = jd.journal_id','LEFT');
$this->db->where("(j.status IN ('posted','reversed') OR jd.id IS NULL)", null, false);
// ================= FILTER AKUN
$this->db->where('a.kategori','laba_rugi');
@@ -85,4 +86,4 @@ class Labarugi extends CI_Controller {
'laba' => $revenue - $expense
]);
}
}
}
+166 -252
View File
@@ -1,284 +1,198 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Leaverequests extends CI_Controller {
class Leaverequests extends MY_Controller
{
private $leaveTypes = array('annual','sick','permit','maternity','unpaid');
public function __construct()
{
parent::__construct();
$this->load->database();
$this->load->library(array('CompanyContext','ApprovalService','AuditService','TransactionService','NumberingService'));
}
// ==========================================
// PAGE
// ==========================================
private function company(){return (int)$this->companycontext->id();}
private function uid(){return (int)$this->session->userdata('user_id');}
private function roleId()
{
$role=(int)$this->session->userdata('role_id');
if($role)return $role;
$user=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();
return $user?(int)$user->role_id:0;
}
private function can($action){return is_master_admin_user()||check_permission('employees',$action);}
private function permissions(){return array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'export'=>$this->can('can_export'));}
public function index()
{
$data = [
"active_menu" => "leave_requests"
];
$data['employees'] = $this->db
->where('is_active',1)
->order_by('full_name','ASC')
->get('k_employees')
->result();
$this->load->view('partials/header', $data);
$this->load->view('employees/leave_requests', $data);
$company=$this->company();
$employees=$this->db->select('id,employee_code,fingerprint_user_id,full_name')->where(array('company_id'=>$company,'is_active'=>1))->order_by('full_name')->get('k_employees')->result();
$counts=$this->workflowCounts();
$data=array('active_menu'=>'leave_requests','employees'=>$employees,'permissions'=>$this->permissions(),'counts'=>$counts);
$this->load->view('partials/header',$data);
$this->load->view('employees/leave_requests',$data);
$this->load->view('partials/footer');
}
// ==========================================
// DATATABLE
// ==========================================
public function get_data()
{
$this->db->select('
lr.*,
e.full_name,
e.fingerprint_user_id
');
$this->db->from('k_leave_requests lr');
$this->db->join(
'k_employees e',
'e.id=lr.employee_id',
'left'
);
$this->db->order_by('lr.id','DESC');
$list = $this->db->get()->result();
$data = [];
$no = 1;
foreach($list as $r){
// =====================================
// LEAVE TYPE
// =====================================
$leave_type = '-';
if($r->leave_type == 'annual'){
$leave_type = 'Cuti Tahunan';
}
if($r->leave_type == 'sick'){
$leave_type = 'Sakit';
}
if($r->leave_type == 'permit'){
$leave_type = 'Izin';
}
if($r->leave_type == 'maternity'){
$leave_type = 'Cuti Melahirkan';
}
if($r->leave_type == 'unpaid'){
$leave_type = 'Cuti Tidak Dibayar';
}
// =====================================
// STATUS
// =====================================
if($r->approval_status == 'approved'){
$status = '
<span class="badge bg-success">
Disetujui
</span>
';
} elseif($r->approval_status == 'rejected'){
$status = '
<span class="badge bg-danger">
Ditolak
</span>
';
} else {
$status = '
<span class="badge bg-warning text-dark">
Pending
</span>
';
}
$data[] = [
$no++,
$r->fingerprint_user_id,
$r->full_name,
$leave_type,
$r->start_date,
$r->end_date,
$r->total_days . ' Hari',
$status,
'
<button class="btn btn-warning btn-sm btn-edit"
data-id="'.$r->id.'">
Edit
</button>
<button class="btn btn-danger btn-sm btn-delete"
data-id="'.$r->id.'">
Delete
</button>
'
];
}
echo json_encode([
"data" => $data
]);
$status=trim((string)$this->input->get('status'));
$type=trim((string)$this->input->get('type'));
$employee=(int)$this->input->get('employee_id');
$this->db->select('lr.id,lr.request_no,lr.employee_id,lr.leave_type,lr.start_date,lr.end_date,lr.total_days,lr.reason,lr.approval_status,lr.workflow_status,lr.submitted_at,lr.approved_at,lr.rejected_at,lr.rejection_reason,lr.created_by,lr.submitted_by,e.full_name,e.employee_code,e.fingerprint_user_id,d.department_name,p.position_name,creator.nama creator_name,approver.nama approver_name,rejector.nama rejector_name')
->from('k_leave_requests lr')->join('k_employees e','e.id=lr.employee_id','left')
->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')
->join('users creator','creator.id=lr.created_by','left')->join('users approver','approver.id=lr.approved_by','left')->join('users rejector','rejector.id=lr.rejected_by','left')
->where('lr.company_id',$this->company());
if(in_array($status,array('draft','submitted','approved','rejected','cancelled'),true))$this->db->where('lr.workflow_status',$status);
if(in_array($type,$this->leaveTypes,true))$this->db->where('lr.leave_type',$type);
if($employee>0)$this->db->where('lr.employee_id',$employee);
$rows=$this->db->order_by('lr.id','DESC')->get()->result();
return json_response(true,'Daftar pengajuan cuti.',array('data'=>$rows,'summary'=>$this->workflowCounts()));
}
// ==========================================
// DETAIL
// ==========================================
public function detail($id)
{
$data = $this->db
->get_where(
'k_leave_requests',
['id'=>$id]
)
->row();
echo json_encode($data);
$leave=$this->find((int)$id);
if(!$leave)return json_response(false,'Pengajuan cuti tidak ditemukan.',array(),404);
$history=array();
$history=$this->db->select('aa.action,aa.notes,aa.created_at,aa.step_order,u.nama user_name,aws.name step_name,ar.request_no approval_no')
->from('approval_actions aa')->join('approval_requests ar','ar.id=aa.request_id')
->join('users u','u.id=aa.user_id','left')->join('approval_workflow_steps aws','aws.workflow_id=ar.workflow_id AND aws.step_order=aa.step_order','left')
->where(array('ar.entity_type'=>'leave_request','ar.entity_id'=>$leave->id))->order_by('aa.id')->get()->result();
return json_response(true,'Detail pengajuan cuti.',array('leave'=>$leave,'history'=>$history,'permissions'=>$this->permissions()));
}
// ==========================================
// SAVE
// ==========================================
public function save()
{
if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);
$id=(int)$this->input->post('id');
$this->requirePermission('employees',$id?'can_update':'can_create');
try{
$start_date = $this->input->post('start_date');
$end_date = $this->input->post('end_date');
$total_days = (
strtotime($end_date)
-
strtotime($start_date)
) / 86400 + 1;
$data = [
'employee_id' => $this->input->post('employee_id'),
'leave_type' => $this->input->post('leave_type'),
'start_date' => $start_date,
'end_date' => $end_date,
'total_days' => $total_days,
'reason' => $this->input->post('reason'),
'approval_status' => $this->input->post('approval_status')
];
$this->db->insert(
'k_leave_requests',
$data
);
echo json_encode([
'status' => true,
'message'=> 'Leave request berhasil ditambahkan'
]);
} catch(Exception $e){
echo json_encode([
'status' => false,
'message'=> $e->getMessage()
]);
}
$saved=$this->transactionservice->run(function()use($id){
$before=$id?$this->lock($id):null;
if($id&&!in_array($before->workflow_status,array('draft','rejected'),true))throw new BusinessException('Hanya Draft atau pengajuan yang ditolak yang dapat diperbaiki.');
$employeeId=(int)$this->input->post('employee_id');
$employee=$this->db->get_where('k_employees',array('id'=>$employeeId,'company_id'=>$this->company(),'is_active'=>1))->row();
if(!$employee)throw new BusinessException('Karyawan aktif tidak ditemukan.');
$type=trim((string)$this->input->post('leave_type'));
if(!in_array($type,$this->leaveTypes,true))throw new BusinessException('Jenis cuti atau izin tidak valid.');
$start=$this->validDate($this->input->post('start_date'),'Tanggal mulai');
$end=$this->validDate($this->input->post('end_date'),'Tanggal selesai');
if($end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');
$days=(int)((strtotime($end)-strtotime($start))/86400)+1;
if($days<1||$days>366)throw new BusinessException('Rentang cuti harus antara 1 sampai 366 hari.');
$reason=trim((string)$this->input->post('reason'));
if($reason==='')throw new BusinessException('Alasan pengajuan wajib diisi.');
if(mb_strlen($reason)>2000)throw new BusinessException('Alasan pengajuan maksimal 2.000 karakter.');
$overlap=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employeeId))->where_in('workflow_status',array('submitted','approved'))->where('start_date <=',$end)->where('end_date >=',$start);
if($id)$overlap->where('id !=',$id);
if($overlap->count_all_results('k_leave_requests'))throw new BusinessException('Karyawan sudah mempunyai pengajuan aktif pada rentang tanggal tersebut.');
$row=array('employee_id'=>$employeeId,'leave_type'=>$type,'start_date'=>$start,'end_date'=>$end,'total_days'=>$days,'reason'=>$reason,'approval_status'=>'pending','workflow_status'=>'draft','rejection_reason'=>null,'rejected_by'=>null,'rejected_at'=>null,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));
if($id){$row['version']=(int)$before->version+1;$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_leave_requests',$row);$leaveId=$id;$auditAction='update';}
else{$row['company_id']=$this->company();$row['request_no']=$this->numberingservice->next('leave_request',$start);$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$row['version']=1;$this->db->insert('k_leave_requests',$row);$leaveId=(int)$this->db->insert_id();$auditAction='create';}
$after=$this->find($leaveId);
$this->auditservice->record('hr','leave_request',$leaveId,$auditAction,$before,$after,$this->uid());
return $leaveId;
});
return json_response(true,$id?'Draft pengajuan berhasil diperbarui.':'Draft pengajuan berhasil dibuat.',array('id'=>$saved));
}catch(Throwable$e){return business_exception_response($e);}
}
// ==========================================
// UPDATE
// ==========================================
public function update()
public function submit($id)
{
try{
$id = $this->input->post('id');
$start_date = $this->input->post('start_date');
$end_date = $this->input->post('end_date');
$total_days = (
strtotime($end_date)
-
strtotime($start_date)
) / 86400 + 1;
$data = [
'employee_id' => $this->input->post('employee_id'),
'leave_type' => $this->input->post('leave_type'),
'start_date' => $start_date,
'end_date' => $end_date,
'total_days' => $total_days,
'reason' => $this->input->post('reason'),
'approval_status' => $this->input->post('approval_status')
];
$this->db->where('id',$id);
$this->db->update(
'k_leave_requests',
$data
);
echo json_encode([
'status' => true,
'message'=> 'Leave request berhasil diupdate'
]);
} catch(Exception $e){
echo json_encode([
'status' => false,
'message'=> $e->getMessage()
]);
}
$this->requirePermission('employees','can_create');
return $this->action(function()use($id){
$before=$this->lock((int)$id);
if($before->workflow_status!=='draft')throw new BusinessException('Hanya Draft yang dapat diajukan.');
$request=$this->approvalservice->create('hr','leave_request',$before->id,0,$this->uid(),array('company_id'=>$this->company(),'request_no'=>$before->request_no,'employee_id'=>$before->employee_id,'employee_name'=>$before->full_name,'leave_type'=>$before->leave_type,'start_date'=>$before->start_date,'end_date'=>$before->end_date,'total_days'=>$before->total_days));
$update=array('workflow_status'=>'submitted','approval_status'=>'pending','approval_request_id'=>(int)$request->id,'submitted_by'=>$this->uid(),'submitted_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'submit',$before,$update,$this->uid());
return $before->id;
},'Pengajuan cuti berhasil dikirim untuk persetujuan.');
}
// ==========================================
// DELETE
// ==========================================
public function delete($id)
public function approve($id)
{
$this->db->delete(
'k_leave_requests',
['id'=>$id]
);
echo json_encode([
'status' => true,
'message'=> 'Leave request berhasil dihapus'
]);
$this->requirePermission('employees','can_approve');
return $this->action(function()use($id){
$before=$this->lock((int)$id);
if($before->workflow_status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');
$request=$this->approvalservice->pendingForEntity('leave_request',$before->id);
if(!$request)throw new BusinessException('Permintaan persetujuan tidak ditemukan.');
$result=$this->approvalservice->act($request->id,'approved',$this->uid(),$this->roleId(),trim((string)$this->input->post('notes'))?:null);
$update=array('updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
if($result->status==='approved'){$update['workflow_status']='approved';$update['approval_status']='approved';$update['approved_by']=$this->uid();$update['approved_at']=date('Y-m-d H:i:s');}
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'approve',$before,$update,$this->uid());
return $before->id;
},'Pengajuan cuti berhasil disetujui.');
}
}
public function reject($id)
{
$this->requirePermission('employees','can_approve');
return $this->action(function()use($id){
$reason=trim((string)$this->input->post('reason'));
if($reason==='')throw new BusinessException('Alasan penolakan wajib diisi.');
$before=$this->lock((int)$id);
if($before->workflow_status!=='submitted')throw new BusinessException('Pengajuan tidak sedang menunggu persetujuan.');
$request=$this->approvalservice->pendingForEntity('leave_request',$before->id);
if(!$request)throw new BusinessException('Permintaan persetujuan tidak ditemukan.');
$this->approvalservice->act($request->id,'rejected',$this->uid(),$this->roleId(),$reason);
$update=array('workflow_status'=>'rejected','approval_status'=>'rejected','rejected_by'=>$this->uid(),'rejected_at'=>date('Y-m-d H:i:s'),'rejection_reason'=>$reason,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'reject',$before,$update,$this->uid());
return $before->id;
},'Pengajuan cuti ditolak dan dapat diperbaiki kembali.');
}
public function cancel($id)
{
if(!$this->can('can_update')&&!$this->can('can_create'))return json_response(false,'Anda tidak memiliki izin untuk membatalkan Draft.',array(),403);
return $this->action(function()use($id){
$before=$this->lock((int)$id);
if(!in_array($before->workflow_status,array('draft','rejected'),true))throw new BusinessException('Hanya Draft atau pengajuan ditolak yang dapat dibatalkan.');
$update=array('workflow_status'=>'cancelled','cancelled_by'=>$this->uid(),'cancelled_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'),'version'=>(int)$before->version+1);
$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('k_leave_requests',$update);
$this->auditservice->record('hr','leave_request',$before->id,'cancel',$before,$update,$this->uid());
return $before->id;
},'Draft pengajuan berhasil dibatalkan.');
}
private function action($callback,$message)
{
if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);
try{$result=$this->transactionservice->run($callback);return json_response(true,$message,array('result'=>$result));}catch(Throwable$e){return business_exception_response($e);}
}
private function lock($id)
{
$row=$this->db->query('SELECT lr.*,e.full_name FROM k_leave_requests lr LEFT JOIN k_employees e ON e.id=lr.employee_id WHERE lr.id=? AND lr.company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();
if(!$row)throw new BusinessException('Pengajuan cuti tidak ditemukan.');
return $row;
}
private function find($id)
{
return $this->db->select('lr.*,e.full_name,e.employee_code,e.fingerprint_user_id,d.department_name,p.position_name,ar.workflow_id,ar.request_no approval_no,ar.current_step,ar.status approval_request_status,creator.nama creator_name,submitter.nama submitter_name,approver.nama approver_name,rejector.nama rejector_name')
->from('k_leave_requests lr')->join('k_employees e','e.id=lr.employee_id','left')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')
->join('approval_requests ar','ar.id=lr.approval_request_id','left')->join('users creator','creator.id=lr.created_by','left')->join('users submitter','submitter.id=lr.submitted_by','left')->join('users approver','approver.id=lr.approved_by','left')->join('users rejector','rejector.id=lr.rejected_by','left')
->where(array('lr.id'=>(int)$id,'lr.company_id'=>$this->company()))->get()->row();
}
private function validDate($value,$label)
{
$value=trim((string)$value);$date=DateTime::createFromFormat('Y-m-d',$value);
if(!$date||$date->format('Y-m-d')!==$value)throw new BusinessException($label.' tidak valid.');
return $value;
}
private function workflowCounts()
{
$counts=array('all'=>0,'draft'=>0,'submitted'=>0,'approved'=>0,'rejected'=>0,'cancelled'=>0);
foreach($this->db->select('workflow_status,COUNT(*) total')->where('company_id',$this->company())->group_by('workflow_status')->get('k_leave_requests')->result() as $row){$counts[$row->workflow_status]=(int)$row->total;$counts['all']+=(int)$row->total;}
return $counts;
}
}
+8 -140
View File
@@ -1,145 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Lokasiasset extends CI_Controller {
public function __construct()
/** Alias kompatibilitas Master Lokasi Aset. */
class Lokasiasset extends MY_Controller
{
public function index(){ redirect('fixedassets#assetMaster', 'location', 301); }
public function __call($method,$arguments)
{
parent::__construct();
if (!$this->session->userdata('logged_in')) {
redirect('auth');
}
if(strtoupper($this->input->method())==='GET') redirect('fixedassets#assetMaster', 'location', 302);
return json_response(false,'Master lokasi lama sudah dinonaktifkan. Gunakan Master Data pada menu Aset Tetap.',array(),410);
}
public function index()
{
$data = ["active_menu" => "lokasiasset"];
$this->load->view('partials/header', $data);
$this->load->view('lokasi_asset/layout');
$this->load->view('partials/footer');
}
// =========================
// GET DATA (DATATABLE)
// =========================
public function get_data()
{
$data = $this->db->get('lokasi_asset')->result();
$result = [];
$no = 1;
foreach ($data as $row) {
$result[] = [
$no++,
$row->nama,
'
<button class="btn btn-sm btn-secondary btn-editgudang" data-id="'.$row->id.'">Edit</button>
<button class="btn btn-sm btn-danger btn-delete" data-id="'.$row->id.'">Delete</button>
'
];
}
echo json_encode(["data"=>$result]);
}
// =========================
// DETAIL
// =========================
public function detail($id)
{
echo json_encode(
$this->db->get_where('lokasi_asset',['id'=>$id])->row()
);
}
// =========================
// SAVE
// =========================
public function save()
{
$nama = $this->input->post('nama');
if(!$nama){
echo json_encode(['status'=>false,'message'=>'Nama wajib diisi']);
return;
}
$this->db->insert('lokasi_asset', [
'nama' => $nama
]);
log_activity(
'kode_barang',
'create',
'Tambah gudang '.$nama,
'success'
);
echo json_encode(['status'=>true,'message'=>'Gudang berhasil ditambahkan']);
}
// =========================
// UPDATE
// =========================
public function update()
{
$id = $this->input->post('id');
$nama = $this->input->post('nama');
if(!$nama){
echo json_encode(['status'=>false,'message'=>'Nama wajib diisi']);
return;
}
$this->db->where('id',$id)->update('lokasi_asset', [
'nama' => $nama
]);
log_activity(
'kode_barang',
'update',
'Update gudang '.$nama,
'success'
);
echo json_encode(['status'=>true,'message'=>'Gudang berhasil diupdate']);
}
// =========================
// DELETE
// =========================
public function delete($id)
{
// optional: cek relasi ke items
$cek = $this->db->get_where('assets',['lokasi_asset_id'=>$id])->num_rows();
if($cek > 0){
echo json_encode([
'status'=>false,
'message'=>'Gudang tidak bisa dihapus karena masih dipakai item'
]);
return;
}
$this->db->delete('lokasi_asset',['id'=>$id]);
log_activity(
'kode_barang',
'delete',
'Delete gudang '.$nama,
'success'
);
echo json_encode(['status'=>true,'message'=>'Gudang berhasil dihapus']);
}
public function list()
{
echo json_encode(
$this->db->get('lokasi_asset')->result()
);
}
}
}
+9
View File
@@ -0,0 +1,9 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Lookups extends MY_Controller
{
public function search($type){
$map=array('customer'=>array('invoices','customers','id','nama',null),'item'=>array('items','items','id','nama_barang','kode_barang'),'account'=>array('jurnal','accounts','id','nama_akun','kode_akun'),'employee'=>array('employees','k_employees','id','full_name','employee_code'),'supplier'=>array('purchases','suppliers','id','name','supplier_code'));
if(!isset($map[$type]))show_404();list($feature,$table,$id,$label,$code)=$map[$type];$this->requirePermission($feature);$term=trim((string)$this->input->get('q',true));$page=max(1,(int)$this->input->get('page'));$limit=min(30,max(10,(int)$this->input->get('limit')?:20));$hasCode=$code&&$this->db->field_exists($code,$table);$this->db->select("$id id,$label label".($hasCode?",$code code":''))->from($table);if($this->db->field_exists('company_id',$table))$this->db->where('company_id',$this->companycontext->id());if($this->db->field_exists('is_active',$table))$this->db->where('is_active',1);if($type==='account'&&$this->db->field_exists('allow_posting','accounts'))$this->db->where('allow_posting',1);if($term!==''){$this->db->group_start()->like($label,$term);if($hasCode)$this->db->or_like($code,$term);$this->db->group_end();}$rows=$this->db->order_by($label)->limit($limit+1,($page-1)*$limit)->get()->result_array();$more=count($rows)>$limit;if($more)array_pop($rows);$results=array_map(function($r){return array('id'=>$r['id'],'text'=>(isset($r['code'])&&$r['code']!==''?$r['code'].' · ':'').$r['label']);},$rows);$this->output->set_content_type('application/json')->set_output(json_encode(array('results'=>$results,'pagination'=>array('more'=>$more))));
}
}
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Maintenanceworker extends CI_Controller
{
public function retention($days=365,$batch=5000){if(!$this->input->is_cli_request())show_404();$days=max(30,(int)$days);$batch=min(20000,max(100,(int)$batch));$cutoff=date('Y-m-d H:i:s',strtotime('-'.$days.' days'));$ids=$this->db->select('id')->where('created_at <',$cutoff)->order_by('id')->limit($batch)->get('activity_logs')->result_array();$count=count($ids);if($count){$list=array_column($ids,'id');$this->db->trans_start();$this->db->query('INSERT IGNORE INTO activity_logs_archive SELECT a.*,NOW() FROM activity_logs a WHERE a.id IN('.implode(',',array_map('intval',$list)).')');$this->db->where_in('id',$list)->delete('activity_logs');$this->db->trans_complete();if(!$this->db->trans_status())throw new RuntimeException('Arsip activity log gagal.');}$this->db->where('expires_at <',date('Y-m-d H:i:s'))->delete('app_cache');$this->db->where('timestamp <',time()-86400)->delete('ci_sessions');$this->db->where('status','completed')->where('completed_at <',date('Y-m-d H:i:s',strtotime('-30 days')))->delete('app_jobs');echo $count." activity log diarsipkan.\n";}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Migrate extends CI_Controller
{
public function __construct()
{
parent::__construct();
if (!$this->input->is_cli_request()) {
show_error('Migration hanya dapat dijalankan melalui CLI.', 403, 'Akses ditolak');
}
$this->load->library('migration');
}
public function latest()
{
$version = $this->migration->latest();
if ($version === false) {
fwrite(STDERR, $this->migration->error_string() . PHP_EOL);
exit(1);
}
if ($version === true) {
$row = $this->db->order_by('version', 'DESC')->limit(1)->get('migrations')->row();
$version = $row ? $row->version : 0;
}
echo 'Migration berhasil. Version: ' . $version . PHP_EOL;
}
public function version($version = 0)
{
$result = $this->migration->version((int) $version);
if ($result === false) {
fwrite(STDERR, $this->migration->error_string() . PHP_EOL);
exit(1);
}
echo 'Migration berhasil. Version: ' . $result . PHP_EOL;
}
public function status()
{
$migration = $this->db->order_by('version', 'DESC')->limit(1)->get('migrations')->row();
echo 'Version: ' . ($migration ? $migration->version : 0) . PHP_EOL;
echo 'document_sequences: ' . ($this->db->table_exists('document_sequences') ? 'ready' : 'missing') . PHP_EOL;
echo 'system_account_mappings: ' . ($this->db->table_exists('system_account_mappings') ? 'ready' : 'missing') . PHP_EOL;
if ($this->db->table_exists('system_account_mappings')) {
echo 'active_mappings: ' . $this->db->where('is_active', 1)->count_all_results('system_account_mappings') . PHP_EOL;
}
foreach (array('customer_equipment_registry','technician_equipment_documents','technician_custody_allocations','technician_equipment_document_lines','technician_equipment_inspections') as $table) {
echo $table . ': ' . ($this->db->table_exists($table) ? 'ready (' . $this->db->count_all($table) . ' rows)' : 'missing') . PHP_EOL;
}
if ($this->db->table_exists('k_employees')) {
echo 'k_employees.is_active: ' . ($this->db->field_exists('is_active','k_employees') ? 'yes' : 'no') . PHP_EOL;
echo 'k_employees.user_id: ' . ($this->db->field_exists('user_id','k_employees') ? 'yes' : 'no') . PHP_EOL;
}
if ($this->db->table_exists('users')) echo 'users.employee_id: ' . ($this->db->field_exists('employee_id','users') ? 'yes' : 'no') . PHP_EOL;
}
public function equipment_audit()
{
$checks=array(
'negative_balances'=>"SELECT COUNT(*) total FROM technician_custody_allocations WHERE qty<=0",
'duplicate_balance_keys'=>"SELECT COUNT(*) total FROM(SELECT balance_key FROM technician_custody_allocations GROUP BY balance_key HAVING COUNT(*)>1)x",
'invalid_unit_quantities'=>"SELECT COUNT(*) total FROM technician_custody_allocations a JOIN item_barcodes b ON b.id=a.barcode_id JOIN items i ON i.id=b.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id WHERE a.subject_type='company_barcode' AND UPPER(COALESCE(k.tracking_type,'QTY'))='UNIT' AND ABS(a.qty-1)>.0001",
'overheld_company_qty'=>"SELECT COUNT(*) total FROM(SELECT a.barcode_id,SUM(a.qty) held,MAX(b.qty_sisa) qty_sisa FROM technician_custody_allocations a JOIN item_barcodes b ON b.id=a.barcode_id WHERE a.subject_type='company_barcode' GROUP BY a.barcode_id HAVING held>qty_sisa+.0001)x",
'orphan_document_lines'=>"SELECT COUNT(*) total FROM technician_equipment_document_lines l LEFT JOIN technician_equipment_documents d ON d.id=l.document_id WHERE d.id IS NULL",
'external_inventory_links'=>"SELECT COUNT(*) total FROM customer_equipment_registry r JOIN technician_custody_allocations a ON a.customer_equipment_id=r.id WHERE a.item_id IS NOT NULL OR a.barcode_id IS NOT NULL"
);
foreach($checks as$name=>$sql){$row=$this->db->query($sql)->row();echo$name.': '.(int)$row->total.PHP_EOL;}
echo 'Audit bersifat read-only; nilai 0 berarti lulus.' . PHP_EOL;
}
public function equipment_smoke()
{
$fixture=$this->db->query("SELECT b.id barcode_id,b.barcode,b.warehouse_id,i.company_id,COALESCE(k.tracking_type,'QTY') tracking_type,(b.qty_sisa-b.reserved_qty-b.return_reserved_qty) available FROM item_barcodes b JOIN items i ON i.id=b.item_id LEFT JOIN kode_barang k ON k.id=i.kode_id WHERE b.qty_sisa-b.reserved_qty-b.return_reserved_qty>=1 AND b.status='available' ORDER BY b.id LIMIT 1")->row();
if(!$fixture){echo"SKIP: tidak ada barcode tersedia untuk smoke test.\n";return;}$employee=$this->db->where(array('company_id'=>$fixture->company_id,'is_active'=>1))->order_by('id')->get('k_employees')->row();$customer=$this->db->where('company_id',$fixture->company_id)->order_by('id')->get('customers')->row();$user=$this->db->order_by('id')->get('users')->row();if(!$employee||!$customer||!$user){echo"SKIP: fixture teknisi/customer/user tidak lengkap.\n";return;}
$this->session->set_userdata(array('company_id'=>(int)$fixture->company_id,'user_id'=>(int)$user->id));$this->load->library('TechnicianEquipmentService');$service=$this->technicianequipmentservice;$prefix='SMOKE-'.date('YmdHis').'-'.bin2hex(random_bytes(3));$beforeJournal=$this->db->count_all('journals');$beforeStock=$this->db->count_all('stock_logs');$this->db->trans_begin(true);
try{$qty=1;$handover=$service->process(array('action'=>'handover','subject_id'=>$fixture->barcode_id,'technician_id'=>$employee->id,'qty'=>$qty,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Serah terima','idempotency_key'=>$prefix.'-H'),$user->id);$duplicate=$service->process(array('action'=>'handover','subject_id'=>$fixture->barcode_id,'technician_id'=>$employee->id,'qty'=>$qty,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Serah terima','idempotency_key'=>$prefix.'-H'),$user->id);if(empty($duplicate['duplicate']))throw new RuntimeException('Idempotency gagal.');$held=$this->db->where(array('company_id'=>$fixture->company_id,'subject_type'=>'company_barcode','subject_id'=>$fixture->barcode_id,'custodian_type'=>'technician','custodian_id'=>$employee->id,'usage_status'=>'carried'))->where('qty >',0)->get('technician_custody_allocations')->row();if(!$held)throw new RuntimeException('Saldo teknisi tidak terbentuk.');$service->process(array('action'=>'install','allocation_id'=>$held->id,'customer_id'=>$customer->id,'qty'=>$qty,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Pemasangan','idempotency_key'=>$prefix.'-I'),$user->id);$installed=$this->db->where(array('company_id'=>$fixture->company_id,'subject_type'=>'company_barcode','subject_id'=>$fixture->barcode_id,'custodian_type'=>'customer','customer_id'=>$customer->id,'usage_status'=>'installed'))->where('qty >',0)->get('technician_custody_allocations')->row();$service->process(array('action'=>'remove','allocation_id'=>$installed->id,'technician_id'=>$employee->id,'qty'=>$qty,'condition_status'=>'inspection_required','date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Pelepasan','idempotency_key'=>$prefix.'-U'),$user->id);$removed=$this->db->where(array('company_id'=>$fixture->company_id,'subject_type'=>'company_barcode','subject_id'=>$fixture->barcode_id,'custodian_type'=>'technician','custodian_id'=>$employee->id,'usage_status'=>'removed'))->where('qty >',0)->get('technician_custody_allocations')->row();$service->process(array('action'=>'return','allocation_id'=>$removed->id,'warehouse_id'=>$fixture->warehouse_id,'qty'=>$qty,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Kembali gudang','idempotency_key'=>$prefix.'-R'),$user->id);$queue=$this->db->where(array('company_id'=>$fixture->company_id,'subject_type'=>'company_barcode','subject_id'=>$fixture->barcode_id,'custodian_type'=>'quarantine','condition_status'=>'inspection_required'))->where('qty >',0)->get('technician_custody_allocations')->row();$service->inspect(array('allocation_id'=>$queue->id,'result_status'=>'good','qty'=>$qty,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Layak','idempotency_key'=>$prefix.'-Q'),$user->id);$external=$service->registerExternal(array('owner_customer_id'=>$customer->id,'equipment_name'=>'TEST BARANG CUSTOMER','external_barcode'=>$prefix.'-EXT','tracking_type'=>'UNIT','qty'=>1),$user->id);if(!empty($external['item_id'])||!empty($external['barcode_id']))throw new RuntimeException('Barang customer terhubung ke inventory.');if($this->db->count_all('journals')!==$beforeJournal||$this->db->count_all('stock_logs')!==$beforeStock)throw new RuntimeException('Modul menulis jurnal/stock_logs.');echo'PASS: '.$fixture->tracking_type.' gudang-teknisi-customer-teknisi-karantina-available, idempotency, dan barang customer non-accounting.'.PHP_EOL;}catch(Throwable$e){echo'FAIL: '.$e->getMessage().PHP_EOL;$this->db->trans_rollback();exit(1);}$this->db->trans_rollback();echo'ROLLBACK: seluruh data smoke test dibatalkan.'.PHP_EOL;
}
public function equipment_qty_smoke()
{
$f=$this->db->query("SELECT b.id barcode_id,b.warehouse_id,i.company_id,(b.qty_sisa-b.reserved_qty-b.return_reserved_qty) available FROM item_barcodes b JOIN items i ON i.id=b.item_id JOIN kode_barang k ON k.id=i.kode_id WHERE UPPER(k.tracking_type)='QTY' AND b.status='available' AND b.qty_sisa-b.reserved_qty-b.return_reserved_qty>=2 ORDER BY b.id LIMIT 1")->row();if(!$f){echo"SKIP: fixture barcode QTY dengan saldo >=2 tidak tersedia.\n";return;}$employee=$this->db->where(array('company_id'=>$f->company_id,'is_active'=>1))->order_by('id')->get('k_employees')->row();$customer=$this->db->where('company_id',$f->company_id)->order_by('id')->get('customers')->row();$user=$this->db->order_by('id')->get('users')->row();if(!$employee||!$customer||!$user){echo"SKIP: fixture tidak lengkap.\n";return;}$this->session->set_userdata(array('company_id'=>(int)$f->company_id,'user_id'=>(int)$user->id));$this->load->library('TechnicianEquipmentService');$s=$this->technicianequipmentservice;$key='SMOKE-QTY-'.bin2hex(random_bytes(5));$this->db->trans_begin(true);try{$s->process(array('action'=>'handover','subject_id'=>$f->barcode_id,'technician_id'=>$employee->id,'qty'=>2,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Partial QTY','idempotency_key'=>$key.'-H'),$user->id);$held=$this->db->where(array('company_id'=>$f->company_id,'subject_id'=>$f->barcode_id,'custodian_type'=>'technician','custodian_id'=>$employee->id,'usage_status'=>'carried'))->get('technician_custody_allocations')->row();$s->process(array('action'=>'install','allocation_id'=>$held->id,'customer_id'=>$customer->id,'qty'=>1,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Pasang sebagian','idempotency_key'=>$key.'-I'),$user->id);$carried=(float)$this->db->select_sum('qty')->where(array('company_id'=>$f->company_id,'subject_id'=>$f->barcode_id,'custodian_type'=>'technician','usage_status'=>'carried'))->get('technician_custody_allocations')->row()->qty;$installed=(float)$this->db->select_sum('qty')->where(array('company_id'=>$f->company_id,'subject_id'=>$f->barcode_id,'custodian_type'=>'customer','usage_status'=>'installed'))->get('technician_custody_allocations')->row()->qty;if($carried<1||$installed<1)throw new RuntimeException('Split saldo QTY tidak terbentuk.');echo"PASS: QTY berpindah dan dipasang sebagian tanpa saldo negatif.\n";}catch(Throwable$e){echo'FAIL: '.$e->getMessage().PHP_EOL;$this->db->trans_rollback();exit(1);}$this->db->trans_rollback();echo"ROLLBACK: data smoke QTY dibatalkan.\n";
}
public function equipment_replacement_smoke()
{
$rows=$this->db->query("SELECT b.id barcode_id,b.warehouse_id,i.company_id FROM item_barcodes b JOIN items i ON i.id=b.item_id WHERE b.status='available' AND b.qty_sisa-b.reserved_qty-b.return_reserved_qty>=1 ORDER BY i.company_id,b.id LIMIT 2")->result();if(count($rows)<2||$rows[0]->company_id!=$rows[1]->company_id){echo"SKIP: dua fixture barcode pada perusahaan sama tidak tersedia.\n";return;}$company=(int)$rows[0]->company_id;$employee=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('id')->get('k_employees')->row();$customer=$this->db->where('company_id',$company)->order_by('id')->get('customers')->row();$user=$this->db->order_by('id')->get('users')->row();if(!$employee||!$customer||!$user){echo"SKIP: fixture tidak lengkap.\n";return;}$this->session->set_userdata(array('company_id'=>$company,'user_id'=>(int)$user->id));$this->load->library('TechnicianEquipmentService');$s=$this->technicianequipmentservice;$key='SMOKE-REPLACE-'.bin2hex(random_bytes(5));$this->db->trans_begin(true);
try{foreach($rows as$i=>$row)$s->process(array('action'=>'handover','subject_id'=>$row->barcode_id,'technician_id'=>$employee->id,'qty'=>1,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Barang penggantian','idempotency_key'=>$key.'-H'.$i),$user->id);$old=$this->db->where(array('company_id'=>$company,'subject_id'=>$rows[0]->barcode_id,'custodian_type'=>'technician','custodian_id'=>$employee->id,'usage_status'=>'carried'))->get('technician_custody_allocations')->row();$s->process(array('action'=>'install','allocation_id'=>$old->id,'customer_id'=>$customer->id,'qty'=>1,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Instalasi lama','idempotency_key'=>$key.'-OLD'),$user->id);$oldInstalled=$this->db->where(array('company_id'=>$company,'subject_id'=>$rows[0]->barcode_id,'custodian_type'=>'customer','customer_id'=>$customer->id,'usage_status'=>'installed'))->get('technician_custody_allocations')->row();$new=$this->db->where(array('company_id'=>$company,'subject_id'=>$rows[1]->barcode_id,'custodian_type'=>'technician','custodian_id'=>$employee->id,'usage_status'=>'carried'))->get('technician_custody_allocations')->row();$doc=$s->replacement(array('customer_id'=>$customer->id,'technician_id'=>$employee->id,'outgoing_allocation_id'=>$new->id,'outgoing_qty'=>1,'incoming_allocation_id'=>$oldInstalled->id,'incoming_qty'=>1,'old_condition'=>'inspection_required','date'=>date('Y-m-d'),'reason'=>'[TEST ROLLBACK] Penggantian','notes'=>'[TEST ROLLBACK] Atomic replacement','idempotency_key'=>$key.'-DOC'),$user->id);$lineCount=$this->db->where('document_id',$doc['document_id'])->count_all_results('technician_equipment_document_lines');if($lineCount!==2)throw new RuntimeException('Dokumen penggantian tidak memiliki dua sisi barang.');$reversal=$s->reverse(array('document_id'=>$doc['document_id'],'date'=>date('Y-m-d'),'reason'=>'[TEST ROLLBACK] Koreksi penggantian','idempotency_key'=>$key.'-REV'),$user->id);$source=$this->db->get_where('technician_equipment_documents',array('id'=>$doc['document_id']))->row();if(!$source||$source->status!=='reversed'||$this->db->where('document_id',$reversal['document_id'])->count_all_results('technician_equipment_document_lines')!==2)throw new RuntimeException('Reversal tidak membentuk histori kebalikan.');echo"PASS: penggantian atomik dan koreksi membuat reversal tanpa menghapus histori.\n";}catch(Throwable$e){echo'FAIL: '.$e->getMessage().PHP_EOL;$this->db->trans_rollback();exit(1);}$this->db->trans_rollback();echo"ROLLBACK: data smoke penggantian dibatalkan.\n";
}
public function equipment_query_smoke()
{
$access=$this->db->order_by('is_default','DESC')->order_by('user_id')->get('user_companies')->row();
if(!$access){echo"SKIP: akses user ke perusahaan belum tersedia.\n";return;}
$company=(int)$access->company_id;
$document=$this->db->where('company_id',$company)->order_by('id')->get('technician_equipment_documents')->row();
$this->session->set_userdata(array('company_id'=>$company,'user_id'=>(int)$access->user_id));
$this->load->library('TechnicianEquipmentService');
try{
$dashboard=$this->technicianequipmentservice->dashboard();
$allocations=$this->technicianequipmentservice->allocations();
$documents=$this->technicianequipmentservice->documents();
$history=$this->technicianequipmentservice->history();
$queue=$this->technicianequipmentservice->inspectionQueue();
$external=$this->technicianequipmentservice->externalList();
$customerSummaries=$this->technicianequipmentservice->customerEquipmentSummary();
if($customerSummaries){foreach(array('all','installed','stored','technician','inspection','good','damaged')as$status)$this->technicianequipmentservice->customerEquipmentRows((int)$customerSummaries[0]['customer_id'],$status);$this->technicianequipmentservice->customerEquipmentNotes((int)$customerSummaries[0]['customer_id']);}
$employee=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('id')->get('k_employees')->row();
if($employee){$this->technicianequipmentservice->allocations(null,null,$employee->id);$this->technicianequipmentservice->technicianHoldings($employee->id);$this->technicianequipmentservice->documents(array('technician_id'=>$employee->id));$this->technicianequipmentservice->history(array('technician_id'=>$employee->id));$this->technicianequipmentservice->inspectionQueue(array('technician_id'=>$employee->id));$this->technicianequipmentservice->externalList($employee->id);$this->technicianequipmentservice->search('a','all',$employee->id);$this->technicianequipmentservice->search('a','handover',$employee->id);$this->technicianequipmentservice->search('a','transfer',$employee->id);$this->technicianequipmentservice->search('a','return',$employee->id);$this->technicianequipmentservice->search('a','customer',$employee->id);}
if($document)$this->technicianequipmentservice->document($document->id);
$barcode=$this->db->select('b.barcode')->from('item_barcodes b')->join('items i','i.id=b.item_id')->where('i.company_id',$company)->order_by('b.id')->get()->row();
if($barcode)$this->technicianequipmentservice->scan($barcode->barcode);
echo 'PASS: query dashboard, scan, saldo, dokumen, histori, pemeriksaan, dan register customer berhasil.' . PHP_EOL;
echo 'ROWS: allocations=' . count($allocations) . ', documents=' . count($documents) . ', history=' . count($history) . ', inspection=' . count($queue) . ', external=' . count($external) . ', customer_groups=' . count($customerSummaries) . PHP_EOL;
}catch(Throwable $e){fwrite(STDERR,'FAIL: '.$e->getMessage().PHP_EOL);exit(1);}
}
public function equipment_batch_smoke()
{
$fixtures=$this->db->query("SELECT b.id barcode_id,b.warehouse_id,i.company_id FROM item_barcodes b JOIN items i ON i.id=b.item_id WHERE b.status='available' AND b.qty_sisa-b.reserved_qty-b.return_reserved_qty>=1 ORDER BY i.company_id,b.id LIMIT 2")->result();
if(count($fixtures)<2||$fixtures[0]->company_id!=$fixtures[1]->company_id){echo"SKIP: dua barcode tersedia pada perusahaan yang sama tidak ditemukan.\n";return;}
$company=(int)$fixtures[0]->company_id;$employees=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('id')->limit(2)->get('k_employees')->result();$access=$this->db->where('company_id',$company)->order_by('is_default','DESC')->get('user_companies')->row();if(count($employees)<2||!$access){echo"SKIP: dua teknisi atau akses perusahaan belum tersedia.\n";return;}
$this->session->set_userdata(array('company_id'=>$company,'user_id'=>(int)$access->user_id));$this->load->library('TechnicianEquipmentService');$service=$this->technicianequipmentservice;$prefix='SMOKE-BATCH-'.bin2hex(random_bytes(5));$items=array_map(function($row){return array('subject_id'=>(int)$row->barcode_id,'qty'=>1);},$fixtures);$this->db->trans_begin(true);
try{$handover=$service->processBatch(array('action'=>'handover','technician_id'=>$employees[0]->id,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Serah terima batch','items'=>$items,'idempotency_key'=>$prefix.'-H'),$access->user_id);$duplicate=$service->processBatch(array('action'=>'handover','technician_id'=>$employees[0]->id,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Serah terima batch','items'=>$items,'idempotency_key'=>$prefix.'-H'),$access->user_id);if(empty($duplicate['duplicate'])||(int)$handover['line_count']!==2)throw new RuntimeException('Dokumen batch atau idempotency tidak valid.');$allocations=array();foreach($fixtures as$fixture){$allocation=$this->db->where(array('company_id'=>$company,'subject_type'=>'company_barcode','subject_id'=>$fixture->barcode_id,'custodian_type'=>'technician','custodian_id'=>$employees[0]->id))->where('qty >',0)->get('technician_custody_allocations')->row();if(!$allocation)throw new RuntimeException('Saldo batch teknisi tidak terbentuk.');$allocations[]=array('allocation_id'=>(int)$allocation->id,'qty'=>1);}$transfer=$service->processBatch(array('action'=>'transfer','technician_id'=>$employees[0]->id,'target_technician_id'=>$employees[1]->id,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Transfer batch','items'=>$allocations,'idempotency_key'=>$prefix.'-T'),$access->user_id);if((int)$transfer['line_count']!==2)throw new RuntimeException('Transfer batch tidak lengkap.');$returns=array();foreach($fixtures as$fixture){$allocation=$this->db->where(array('company_id'=>$company,'subject_type'=>'company_barcode','subject_id'=>$fixture->barcode_id,'custodian_type'=>'technician','custodian_id'=>$employees[1]->id))->where('qty >',0)->get('technician_custody_allocations')->row();if(!$allocation)throw new RuntimeException('Saldo hasil transfer tidak ditemukan.');$returns[]=array('allocation_id'=>(int)$allocation->id,'qty'=>1);}$returned=$service->processBatch(array('action'=>'return','technician_id'=>$employees[1]->id,'warehouse_id'=>$fixtures[0]->warehouse_id,'date'=>date('Y-m-d'),'notes'=>'[TEST ROLLBACK] Kembali batch','items'=>$returns,'idempotency_key'=>$prefix.'-R'),$access->user_id);if((int)$returned['line_count']!==2)throw new RuntimeException('Pengembalian batch tidak lengkap.');echo"PASS: dua barang diserahkan, ditransfer, dan dikembalikan secara batch; double submit tetap idempotent.\n";}catch(Throwable$e){echo'FAIL: '.$e->getMessage().PHP_EOL;$this->db->trans_rollback();exit(1);}$this->db->trans_rollback();echo"ROLLBACK: data smoke batch dibatalkan.\n";
}
}
+5 -3
View File
@@ -2,7 +2,7 @@
defined('BASEPATH') OR exit('No direct script access allowed');
class Neraca extends CI_Controller {
class Neraca extends MY_Admin_Controller {
public function __construct()
{
@@ -39,6 +39,7 @@ class Neraca extends CI_Controller {
$this->db->from('accounts');
$this->db->join('journal_details jd','jd.account_id = accounts.id','LEFT');
$this->db->join('journals j','j.id = jd.journal_id','LEFT');
$this->db->where("(j.status IN ('posted','reversed') OR jd.id IS NULL)", null, false);
$this->db->where_in('accounts.tipe',['revenue','expense']);
@@ -88,6 +89,7 @@ class Neraca extends CI_Controller {
$this->db->from('accounts a');
$this->db->join('journal_details jd','jd.account_id = a.id','LEFT');
$this->db->join('journals j','j.id = jd.journal_id','LEFT');
$this->db->where("(j.status IN ('posted','reversed') OR jd.id IS NULL)", null, false);
$this->db->where('a.kategori','neraca');
@@ -129,7 +131,7 @@ class Neraca extends CI_Controller {
}
// ================= LABA BERJALAN
if ($r->kode_akun == '302') {
if ($r->kode_akun == '3020') {
$saldo = $laba;
}
@@ -167,4 +169,4 @@ class Neraca extends CI_Controller {
'selisih' => $total_activa - $total_pasiva
]);
}
}
}
+4 -3
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Neracasaldo extends CI_Controller {
class Neracasaldo extends MY_Admin_Controller {
public function __construct()
{
@@ -49,7 +49,8 @@ class Neracasaldo extends CI_Controller {
"(SELECT jd.*
FROM journal_details jd
JOIN journals j ON j.id = jd.journal_id
WHERE j.tanggal BETWEEN ".$this->db->escape($tanggal_awal)."
WHERE j.status IN ('posted','reversed')
AND j.tanggal BETWEEN ".$this->db->escape($tanggal_awal)."
AND ".$this->db->escape($tanggal_akhir)."
) journal_details",
"journal_details.account_id = accounts.id",
@@ -76,4 +77,4 @@ class Neracasaldo extends CI_Controller {
echo json_encode($rows);
}
}
}
@@ -0,0 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Organizationmaster extends MY_Controller
{
public function index(){$d=array('active_menu'=>'organization_master');$this->load->view('partials/header',$d);$this->load->view('hr_payroll/organization_master',$d);$this->load->view('partials/footer');}
public function data(){return json_response(true,'Master organisasi.',array('departments'=>$this->db->order_by('department_name')->get('k_departments')->result(),'positions'=>$this->db->select('p.*,d.department_name')->from('k_positions p')->join('k_departments d','d.id=p.department_id','left')->order_by('p.position_name')->get()->result()));}
public function save_department(){return$this->saveMaster('k_departments','department_name');}
public function save_position(){return$this->saveMaster('k_positions','position_name',true);}
private function saveMaster($table,$nameField,$position=false){$id=(int)$this->input->post('id');$code=strtoupper(trim($this->input->post('code',true)));$name=trim($this->input->post('name',true));if($code===''||$name==='')return json_response(false,'Kode dan nama wajib diisi.',array(),422);$q=$this->db->where('code',$code);if($id)$q->where('id !=',$id);if($q->count_all_results($table))return json_response(false,'Kode sudah digunakan.',array(),422);$data=array('code'=>$code,$nameField=>$name,'description'=>trim($this->input->post('description',true))?:null,'is_active'=>$this->input->post('is_active')?1:0);if($position)$data['department_id']=$this->input->post('department_id')?:null;if($id)$this->db->where('id',$id)->update($table,$data);else{$this->db->insert($table,$data);$id=$this->db->insert_id();}return json_response(true,'Data berhasil disimpan.',array('id'=>$id));}
public function delete_department($id){return$this->deactivate('k_departments',$id,'department_id');}
public function delete_position($id){return$this->deactivate('k_positions',$id,'position_id');}
private function deactivate($table,$id,$employeeField){$id=(int)$id;$used=$this->db->where($employeeField,$id)->count_all_results('k_employees')>0;if($table==='k_departments'&&$this->db->where('department_id',$id)->count_all_results('k_positions')>0)$used=true;if($used){$this->db->where('id',$id)->update($table,array('is_active'=>0));return json_response(true,'Data sudah digunakan, sehingga dinonaktifkan dan tidak dihapus.');}$this->db->where('id',$id)->delete($table);return json_response(true,'Data berhasil dihapus.');}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Payablecheck extends CI_Controller
{
public function __construct(){parent::__construct();if(!is_cli())show_404();}
public function schema(){foreach(array('items','item_barcodes','warehouses','stock_logs','item_movements','accounts')as$t){echo"---$t---\n";foreach($this->db->field_data($t)as$f)echo$f->name.' | '.$f->type.' | '.$f->max_length."\n";}echo'legacy_purchase_movements='.$this->db->where('movement_type','purchase')->count_all_results('item_movements')."\n";echo'legacy_purchase_journals='.$this->db->where_in('ref_type',array('new_items','stock_logs'))->count_all_results('journals')."\n";}
public function accounts(){foreach($this->db->group_start()->like('nama_akun','hutang')->or_like('nama_akun','utang')->or_like('nama_akun','pembelian')->or_like('nama_akun','persediaan')->group_end()->order_by('kode_akun')->get('accounts')->result()as$a)echo$a->id.' | '.$a->kode_akun.' | '.$a->nama_akun."\n";}
public function integrity(){$checks=array('duplicate_supplier_invoice'=>"SELECT COUNT(*) total FROM(SELECT supplier_id,supplier_invoice_no FROM supplier_invoices GROUP BY supplier_id,supplier_invoice_no HAVING COUNT(*)>1)x",'over_received_po'=>"SELECT COUNT(*) total FROM purchase_order_lines WHERE received_qty>qty+0.0001",'over_invoiced_po'=>"SELECT COUNT(*) total FROM purchase_order_lines WHERE invoiced_qty>received_qty+0.0001",'overpaid_supplier_invoice'=>"SELECT COUNT(*) total FROM supplier_invoices WHERE paid_amount>total+0.01 OR balance<0",'overallocated_supplier_payment'=>"SELECT COUNT(*) total FROM(SELECT p.id FROM supplier_payments p JOIN supplier_payment_allocations a ON a.supplier_payment_id=p.id GROUP BY p.id,p.amount HAVING SUM(a.amount)>p.amount+0.01)x",'duplicate_receipt_stock'=>"SELECT COUNT(*) total FROM(SELECT ref_id,item_id FROM stock_logs WHERE ref_type='goods_receipt' GROUP BY ref_id,item_id HAVING COUNT(*)>1)x",'unbalanced_purchase_journals'=>"SELECT COUNT(*) total FROM(SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('supplier_invoice','supplier_payment','supplier_debit_note','supplier_opening_balance') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>0.01)x");$fail=0;foreach($checks as$n=>$q){$v=(int)$this->db->query($q)->row()->total;echo($v?'[FAIL] ':'[OK] ').$n.'='.$v."\n";if($v)$fail++;}foreach(array('accounts_payable','purchase_input_tax','supplier_advances','payable_opening_balance')as$k){$v=$this->db->where(array('mapping_key'=>$k,'is_active'=>1))->count_all_results('system_account_mappings');echo($v?'[OK] ':'[FAIL] ').'mapping_'.$k."\n";if(!$v)$fail++;}if($fail)exit(1);}
public function invoice_payment_values(){$q="SELECT si.id,si.internal_no,si.status,si.total invoice_total,si.paid_amount invoice_paid,si.balance,po.id po_id,po.po_no,po.subtotal,po.tax_total,po.shipping_cost,po.insurance_cost,po.service_fee,po.discount_amount,po.cashback_amount,po.total po_total,po.paid_amount po_paid,(SELECT COALESCE(SUM(pol2.line_total),0) FROM purchase_order_lines pol2 WHERE pol2.purchase_order_id=po.id) item_total,(SELECT COALESCE(SUM(sp.amount),0) FROM supplier_payments sp WHERE sp.purchase_order_id=po.id AND sp.status='posted') payment_total FROM supplier_invoices si JOIN supplier_invoice_lines sil ON sil.supplier_invoice_id=si.id JOIN purchase_order_lines pol ON pol.id=sil.po_line_id JOIN purchase_orders po ON po.id=pol.purchase_order_id GROUP BY si.id ORDER BY si.id DESC";foreach($this->db->query($q)->result()as$r)echo json_encode($r,JSON_UNESCAPED_UNICODE)."\n";}
}
+331
View File
@@ -0,0 +1,331 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Payables extends MY_Controller
{
public function __construct()
{
parent::__construct();
$this->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;
}
}
+10 -593
View File
@@ -1,598 +1,15 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Payroll extends CI_Controller {
public function __construct()
/**
* Compatibility bridge untuk bookmark/URL Payroll lama.
* Seluruh proses payroll sekarang hanya dijalankan oleh Hrpayroll + PayrollService.
*/
class Payroll extends MY_Controller
{
public function _remap($method, $parameters = array())
{
parent::__construct();
$this->load->database();
date_default_timezone_set('Asia/Jakarta');
$this->session->set_flashdata('success', 'Payroll lama telah disatukan ke Payroll profesional.');
redirect('hrpayroll');
}
// =====================================================
// INDEX
// =====================================================
public function index()
{
$data = [
'active_menu' => 'payroll'
];
$this->load->view('partials/header',$data);
$this->load->view('employees/payroll');
$this->load->view('partials/footer');
}
// =====================================================
// GET DATA
// =====================================================
public function get_data()
{
$month = $this->input->post('month');
$this->db->select("
p.*,
e.full_name,
pp.period_name,
pp.start_date,
pp.end_date
");
$this->db->from('k_payrolls p');
$this->db->join(
'k_employees e',
'e.id = p.employee_id'
);
$this->db->join(
'k_payroll_periods pp',
'pp.id = p.payroll_period_id'
);
if($month){
$this->db->where(
'DATE_FORMAT(pp.start_date,"%Y-%m")',
$month
);
}
$this->db->order_by(
'e.full_name',
'ASC'
);
$rows = $this->db->get()->result();
$data = [];
$no = 1;
foreach($rows as $row){
$pendapatan =
(
$row->allowance_amount
+
$row->bonus_amount
+
$row->overtime_amount
);
$potongan =
(
$row->deduction_amount
+
$row->bpjs_kesehatan_amount
+
$row->bpjs_ketenagakerjaan_amount
+
$row->tax_amount
+
$row->attendance_cut_amount
);
$status = '';
if($row->payment_status == 'paid'){
$status = '
<span class="badge bg-success">
Paid
</span>
';
} else {
$status = '
<span class="badge bg-warning text-dark">
Draft
</span>
';
}
$data[] = [
$no++,
$row->full_name,
'Rp '.number_format(
$row->basic_salary,
0,
',',
'.'
),
'Rp '.number_format(
$pendapatan,
0,
',',
'.'
),
'Rp '.number_format(
$potongan,
0,
',',
'.'
),
'Rp '.number_format(
$row->total_salary,
0,
',',
'.'
),
$status,
'
<button
class="btn btn-sm btn-warning btn-edit"
data-id="'.$row->id.'">
<i class="fa fa-edit"></i>
Edit
</button>
'
];
}
echo json_encode([
'data' => $data
]);
}
// =====================================================
// DETAIL
// =====================================================
public function detail($id)
{
$this->db->select("
p.*,
e.full_name
");
$this->db->from('k_payrolls p');
$this->db->join(
'k_employees e',
'e.id = p.employee_id'
);
$this->db->where(
'p.id',
$id
);
$payroll = $this->db->get()->row();
if(!$payroll){
echo json_encode([
'status' => false,
'message' => 'Payroll tidak ditemukan'
]);
return;
}
$items = $this->db
->where('payroll_id',$id)
->order_by('id','ASC')
->get('k_payroll_items')
->result();
echo json_encode([
'status' => true,
'payroll' => $payroll,
'employee' => [
'full_name' => $payroll->full_name
],
'items' => $items
]);
}
// =====================================================
// SAVE ITEMS
// =====================================================
public function save_items()
{
$payrollId = $this->input->post('payroll_id');
$items = $this->input->post('items');
$payroll = $this->db
->where('id',$payrollId)
->get('k_payrolls')
->row();
if(!$payroll){
echo json_encode([
'status' => false,
'message' => 'Payroll tidak ditemukan'
]);
return;
}
// =========================================
// DELETE OLD ITEMS
// =========================================
$this->db
->where('payroll_id',$payrollId)
->delete('k_payroll_items');
$allowance = 0;
$bonus = 0;
$overtime = 0;
$deduction = 0;
$bpjs = 0;
$tax = 0;
if($items){
foreach($items as $item){
$amount = (float)$item['amount'];
$insert = [
'payroll_id' => $payrollId,
'item_type' => $item['item_type'],
'item_name' => $item['item_name'],
'amount' => $amount
];
$this->db->insert(
'k_payroll_items',
$insert
);
switch($item['item_type']){
case 'allowance':
$allowance += $amount;
break;
case 'bonus':
$bonus += $amount;
break;
case 'overtime':
$overtime += $amount;
break;
case 'deduction':
$deduction += $amount;
break;
case 'bpjs':
$bpjs += $amount;
break;
case 'tax':
$tax += $amount;
break;
}
}
}
// =========================================
// TOTAL
// =========================================
$totalSalary =
(
$payroll->basic_salary
+
$allowance
+
$bonus
+
$overtime
)
-
(
$deduction
+
$bpjs
+
$tax
+
$payroll->attendance_cut_amount
);
// =========================================
// UPDATE PAYROLL
// =========================================
$this->db
->where('id',$payrollId)
->update(
'k_payrolls',
[
'allowance_amount' => $allowance,
'bonus_amount' => $bonus,
'overtime_amount' => $overtime,
'deduction_amount' => $deduction,
'bpjs_kesehatan_amount' => $bpjs,
'tax_amount' => $tax,
'total_salary' => $totalSalary
]
);
echo json_encode([
'status' => true,
'message' => 'Payroll berhasil disimpan'
]);
}
// =====================================================
// GENERATE PAYROLL
// =====================================================
public function generate_payroll()
{
$month = $this->input->post('month');
if(!$month){
echo json_encode([
'status' => false,
'message' => 'Bulan wajib dipilih'
]);
return;
}
$startDate = date(
'Y-m-01',
strtotime($month)
);
$endDate = date(
'Y-m-t',
strtotime($month)
);
// =========================================
// PERIOD
// =========================================
$period = $this->db
->where('start_date',$startDate)
->where('end_date',$endDate)
->get('k_payroll_periods')
->row();
if(!$period){
$this->db->insert(
'k_payroll_periods',
[
'period_name' => date(
'F Y',
strtotime($startDate)
),
'start_date' => $startDate,
'end_date' => $endDate,
'status' => 'draft',
'generated_at' => date('Y-m-d H:i:s')
]
);
$periodId = $this->db->insert_id();
} else {
$periodId = $period->id;
}
// =========================================
// EMPLOYEES
// =========================================
$employees = $this->db
->where('is_active',1)
->get('k_employees')
->result();
foreach($employees as $employee){
// =====================================
// ATTENDANCE
// =====================================
$attendance = $this->db
->select("
COUNT(*) as total_work_days,
SUM(attendance_status='present')
as total_present,
SUM(attendance_status='alpha')
as total_alpha,
SUM(overtime_hours)
as total_overtime_hours
")
->where('employee_id',$employee->id)
->where('attendance_date >=',$startDate)
->where('attendance_date <=',$endDate)
->get('k_attendances')
->row();
$dailySalary =
$employee->basic_salary / 30;
$alphaCut =
(
$attendance->total_alpha
* $dailySalary
);
$overtimeRate = 25000;
$overtimeAmount =
(
$attendance->total_overtime_hours
* $overtimeRate
);
$totalSalary =
(
$employee->basic_salary
+ $overtimeAmount
)
- $alphaCut;
$exist = $this->db
->where(
'payroll_period_id',
$periodId
)
->where(
'employee_id',
$employee->id
)
->get('k_payrolls')
->row();
$data = [
'payroll_period_id' => $periodId,
'employee_id' => $employee->id,
'basic_salary' => $employee->basic_salary,
'total_work_days' => (
$attendance->total_work_days ?? 0
),
'total_present' => (
$attendance->total_present ?? 0
),
'total_alpha' => (
$attendance->total_alpha ?? 0
),
'total_overtime_hours' => (
$attendance->total_overtime_hours ?? 0
),
'attendance_cut_amount' => $alphaCut,
'overtime_amount' => $overtimeAmount,
'total_salary' => $totalSalary
];
if($exist){
$this->db
->where('id',$exist->id)
->update(
'k_payrolls',
$data
);
} else {
$this->db->insert(
'k_payrolls',
$data
);
$payrollId = $this->db->insert_id();
// =================================
// AUTO ITEM OVERTIME
// =================================
if($overtimeAmount > 0){
$this->db->insert(
'k_payroll_items',
[
'payroll_id' => $payrollId,
'item_type' => 'overtime',
'item_name' => 'Lembur',
'amount' => $overtimeAmount
]
);
}
// =================================
// AUTO ITEM ALPHA
// =================================
if($alphaCut > 0){
$this->db->insert(
'k_payroll_items',
[
'payroll_id' => $payrollId,
'item_type' => 'deduction',
'item_name' => 'Potongan Alpha',
'amount' => $alphaCut
]
);
}
}
}
echo json_encode([
'status' => true,
'message' => 'Payroll berhasil digenerate'
]);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Pemeriksaan CLI terarah. Seluruh fixture selalu di-rollback. */
class Payrollcheck extends CI_Controller
{
public function audit()
{
if(!$this->input->is_cli_request())show_404();
$this->load->database();
$checks=array(
'Baris payroll lintas company / orphan'=>"SELECT COUNT(*) total FROM k_payrolls x LEFT JOIN k_payroll_periods p ON p.id=x.payroll_period_id LEFT JOIN k_employees e ON e.id=x.employee_id WHERE p.id IS NULL OR e.id IS NULL OR p.company_id<>x.company_id OR e.company_id<>x.company_id",
'Rincian kalkulasi tanpa header payroll'=>"SELECT COUNT(*) total FROM payroll_calculation_lines l LEFT JOIN k_payrolls x ON x.id=l.payroll_id WHERE x.id IS NULL",
'Penyesuaian dengan relasi/jenis tidak konsisten'=>"SELECT COUNT(*) total FROM payroll_period_adjustments a LEFT JOIN k_payroll_periods p ON p.id=a.payroll_period_id LEFT JOIN k_employees e ON e.id=a.employee_id LEFT JOIN payroll_components c ON c.id=a.component_id WHERE p.id IS NULL OR e.id IS NULL OR c.id IS NULL OR p.company_id<>a.company_id OR e.company_id<>a.company_id OR c.company_id<>a.company_id OR a.adjustment_type<>c.component_type",
'Periode lanjut proses tanpa hasil hitung'=>"SELECT COUNT(*) total FROM k_payroll_periods WHERE workflow_status IN('reviewed','approved','final','posted','paid') AND (calculated_at IS NULL OR calculation_hash IS NULL)",
'Periode posted/paid tanpa jurnal'=>"SELECT COUNT(*) total FROM k_payroll_periods p LEFT JOIN journals j ON j.id=p.journal_id WHERE p.workflow_status IN('posted','paid') AND j.id IS NULL",
'Periode paid tanpa pembayaran'=>"SELECT COUNT(*) total FROM k_payroll_periods p LEFT JOIN payroll_payments y ON y.id=p.payment_transaction_id AND y.company_id=p.company_id WHERE p.workflow_status='paid' AND y.id IS NULL",
'Rentang assignment karyawan tidak valid'=>"SELECT COUNT(*) total FROM employee_payroll_components WHERE effective_to IS NOT NULL AND effective_to<effective_from",
'Rentang template posisi tidak valid'=>"SELECT COUNT(*) total FROM position_payroll_components WHERE effective_to IS NOT NULL AND effective_to<effective_from",
'Assignment karyawan aktif saling tumpang tindih'=>"SELECT COUNT(*) total FROM employee_payroll_components a JOIN employee_payroll_components b ON b.id>a.id AND b.company_id=a.company_id AND b.employee_id=a.employee_id AND b.component_id=a.component_id AND b.is_active=1 WHERE a.is_active=1 AND a.effective_from<=COALESCE(b.effective_to,'9999-12-31') AND b.effective_from<=COALESCE(a.effective_to,'9999-12-31')",
'Template posisi aktif saling tumpang tindih'=>"SELECT COUNT(*) total FROM position_payroll_components a JOIN position_payroll_components b ON b.id>a.id AND b.company_id=a.company_id AND b.position_id=a.position_id AND b.component_id=a.component_id AND b.is_active=1 WHERE a.is_active=1 AND a.effective_from<=COALESCE(b.effective_to,'9999-12-31') AND b.effective_from<=COALESCE(a.effective_to,'9999-12-31')",
'Assignment karyawan lintas company'=>"SELECT COUNT(*) total FROM employee_payroll_components x JOIN k_employees e ON e.id=x.employee_id JOIN payroll_components c ON c.id=x.component_id WHERE e.company_id<>x.company_id OR c.company_id<>x.company_id",
'Template posisi lintas company'=>"SELECT COUNT(*) total FROM position_payroll_components x JOIN payroll_components c ON c.id=x.component_id WHERE c.company_id<>x.company_id",
'Jurnal payroll tidak balance'=>"SELECT COUNT(*) total FROM (SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('payroll','payroll_payment') GROUP BY j.id HAVING ABS(SUM(d.debit-d.kredit))>0.01) q"
);
$issues=0;foreach($checks as$name=>$sql){$count=(int)$this->db->query($sql)->row()->total;echo($count?'ISSUE':'PASS').': '.$name.' = '.$count.PHP_EOL;$issues+=$count;}
if($issues){fwrite(STDERR,'FAIL: ditemukan '.$issues.' inkonsistensi payroll. Periksa baris ISSUE sebelum proses berikutnya.'.PHP_EOL);exit(1);}echo"PASS: audit integritas payroll tidak menemukan inkonsistensi.\n";
}
public function exports()
{
if(!$this->input->is_cli_request())show_404();
$this->load->database();if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php';
$period=$this->db->order_by('id')->get('k_payroll_periods')->row();if(!$period){echo"SKIP: periode payroll belum tersedia.\n";return;}
$company=$this->db->get_where('companies',array('id'=>$period->company_id))->row();$rows=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where('x.payroll_period_id',$period->id)->limit(3)->get()->result();
$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();if(strlen($pdf->output())<500)throw new RuntimeException('Render PDF payroll gagal.');
$book=new PhpOffice\PhpSpreadsheet\Spreadsheet();$book->getActiveSheet()->fromArray(array(array('Kode','Nama','Neto'),array($rows?$rows[0]->employee_code:'-',$rows?$rows[0]->full_name:'-', $rows?(float)$rows[0]->total_salary:0)),null,'A1');$file=tempnam(sys_get_temp_dir(),'payroll-check-').'.xlsx';(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($file);if(!is_file($file)||filesize($file)<1000)throw new RuntimeException('Render XLSX payroll gagal.');unlink($file);echo"PASS: satu PDF register dan satu XLSX payroll berhasil dirender; file uji dihapus.\n";
}
public function index()
{
if(!$this->input->is_cli_request())show_404();
$this->load->database();$this->load->library('PayrollService');
$company=$this->db->order_by('id')->get('companies')->row();
if(!$company){echo"SKIP: perusahaan tidak tersedia.\n";return;}
$employee=$this->db->where(array('company_id'=>$company->id,'is_active'=>1,'salary_restricted'=>0))->order_by('id')->get('k_employees')->row();
$user=$this->db->order_by('id')->get('users')->row();
if(!$employee||!$user){echo"SKIP: karyawan biasa atau user tidak tersedia.\n";return;}
$access=$this->db->get_where('user_companies',array('company_id'=>$company->id))->row();if(!$access){echo"SKIP: akses user perusahaan tidak tersedia.\n";return;}$user=$this->db->get_where('users',array('id'=>$access->user_id))->row();$this->session->set_userdata(array('company_id'=>(int)$company->id,'user_id'=>(int)$user->id));
$this->db->trans_begin(true);
try{
$start='2098-08-14';$end='2098-09-13';$now=date('Y-m-d H:i:s');
$this->db->insert('k_payroll_periods',array('company_id'=>$company->id,'period_name'=>'[TEST ROLLBACK] Flexible payroll','start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>'2098-09-25','status'=>'draft','workflow_status'=>'draft','generated_at'=>$now));$periodId=(int)$this->db->insert_id();
$componentIds=array();foreach(array(array('SMOKE_EARN','[TEST] Pendapatan','earning'),array('SMOKE_DED','[TEST] Potongan Tetap','deduction'),array('SMOKE_ALPHA','[TEST] Potongan Alpha','deduction'),array('SMOKE_BONUS','[TEST] Bonus Periode','earning'))as$component){$this->db->insert('payroll_components',array('company_id'=>$company->id,'code'=>$component[0].'-'.bin2hex(random_bytes(2)),'name'=>$component[1],'component_type'=>$component[2],'category'=>'other','calculation_method'=>'fixed','default_value'=>0,'fixed_amount'=>0,'taxable'=>0,'prorated'=>0,'is_active'=>1,'effective_from'=>$start,'created_at'=>$now));$componentIds[]=(int)$this->db->insert_id();}
foreach(array(array($componentIds[0],100),array($componentIds[1],10))as$assignment)$this->db->insert('employee_payroll_components',array('company_id'=>$company->id,'employee_id'=>$employee->id,'component_id'=>$assignment[0],'calculation_method'=>'fixed','amount_override'=>$assignment[1],'effective_from'=>$start,'is_active'=>1,'notes'=>'[TEST ROLLBACK]','created_at'=>$now));
$this->db->insert('payroll_attendance_rules',array('company_id'=>$company->id,'component_id'=>$componentIds[2],'rule_name'=>'[TEST] Alpha per hari','condition_type'=>'alpha','calculation_method'=>'per_day','rate_value'=>25,'effective_from'=>$start,'is_active'=>1,'created_at'=>$now));
$this->db->insert('payroll_period_adjustments',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'employee_id'=>$employee->id,'component_id'=>$componentIds[3],'adjustment_type'=>'earning','amount'=>50,'notes'=>'[TEST ROLLBACK] Bonus','is_active'=>1,'created_by'=>$user->id,'created_at'=>$now));
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-01','attendance_status'=>'alpha','late_minutes'=>0,'early_leave_minutes'=>0));
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-02','attendance_status'=>'late','late_minutes'=>30,'early_leave_minutes'=>0));
$this->db->insert('payroll_attendance_locks',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$user->id,'locked_at'=>$now));
$this->db->where('id',$employee->id)->update('k_employees',array('salary_restricted'=>1));$restrictedBlocked=false;try{$this->payrollservice->calculate($periodId,$user->id,false,$company->id);}catch(Throwable$expected){$restrictedBlocked=true;}if(!$restrictedBlocked)throw new RuntimeException('Perhitungan gaji terbatas tidak diblokir untuk user tanpa Manage All Gaji.');$this->db->where('id',$employee->id)->update('k_employees',array('salary_restricted'=>0,'is_active'=>0,'resign_date'=>'2098-09-01'));
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$first=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$second=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
$codes=$this->db->select('c.name,l.amount')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->join('payroll_components c','c.id=l.component_id')->where('p.payroll_period_id',$periodId)->where_in('c.id',$componentIds)->get()->result();$found=array();foreach($codes as$row)$found[$row->name]=(float)$row->amount;
if((int)$first->count!==(int)$second->count)throw new RuntimeException('Recalculate menggandakan atau menghilangkan baris.');
if(($found['[TEST] Pendapatan']??0)!==100.0||($found['[TEST] Potongan Tetap']??0)!==10.0||($found['[TEST] Potongan Alpha']??0)!==25.0||($found['[TEST] Bonus Periode']??0)!==50.0)throw new RuntimeException('Pendapatan, potongan, aturan alpha, atau bonus tidak sesuai.');
$snapshot=$this->db->select('calculation_snapshot')->get_where('k_payrolls',array('payroll_period_id'=>$periodId,'employee_id'=>$employee->id))->row();if(!$snapshot||strpos($snapshot->calculation_snapshot,$start)===false||strpos($snapshot->calculation_snapshot,$end)===false)throw new RuntimeException('Snapshot rentang fleksibel tidak tersimpan.');
$late=$this->db->where(array('company_id'=>$company->id,'condition_type'=>'late','calculation_method'=>'none'))->count_all_results('payroll_attendance_rules');if(!$late)throw new RuntimeException('Default terlambat tanpa potongan tidak tersedia.');
$beforeJournal=(int)$this->db->count_all('journals');$this->db->where('id',$periodId)->update('k_payroll_periods',array('workflow_status'=>'final'));if((int)$this->db->count_all('journals')!==$beforeJournal)throw new RuntimeException('Finalisasi membuat jurnal sebelum Posting.');$journalId=$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);$balance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$journalId))->row();if(!$balance||abs((float)$balance->difference)>.01)throw new RuntimeException('Jurnal payroll tidak balance.');$doubleBlocked=false;try{$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);}catch(Throwable$expected){$doubleBlocked=true;}if(!$doubleBlocked)throw new RuntimeException('Double posting tidak diblokir.');
$cashQuery=$this->db->select('c.*')->from('cash_accounts c')->join('accounts a','a.id=c.gl_account_id')->where(array('c.is_active'=>1,'a.is_active'=>1,'a.allow_posting'=>1,'a.company_id'=>$company->id));if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('c.company_id',$company->id);$cash=$cashQuery->order_by('c.id')->get()->row();if($cash){$paymentJournal=$this->payrollservice->pay($periodId,'2026-09-13',$cash->id,$user->id,$company->id,true);$paid=$this->db->get_where('k_payroll_periods',array('id'=>$periodId,'workflow_status'=>'paid'))->row();$payment=$this->db->get_where('payroll_payments',array('company_id'=>$company->id,'payroll_period_id'=>$periodId))->row();$paymentBalance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$paymentJournal))->row();if(!$paid||!$payment||!$paymentBalance||abs((float)$paymentBalance->difference)>.01)throw new RuntimeException('Pembayaran payroll atau jurnal pembayarannya tidak konsisten.');$doublePayBlocked=false;try{$this->payrollservice->pay($periodId,'2026-09-13',$cash->id,$user->id,$company->id,true);}catch(Throwable$expected){$doublePayBlocked=true;}if(!$doublePayBlocked)throw new RuntimeException('Double payment tidak diblokir.');echo"PASS: periode fleksibel, kalkulasi, posting, pembayaran, jurnal balance, dan idempotency tervalidasi.\n";}else echo"PASS: kalkulasi dan posting tervalidasi. SKIP pembayaran: Kas/Bank dengan akun GL aktif belum tersedia.\n";
}catch(Throwable$e){$this->db->trans_rollback();fwrite(STDERR,'FAIL: '.$e->getMessage().PHP_EOL);exit(1);}
$this->db->trans_rollback();echo"ROLLBACK: seluruh fixture payroll dibatalkan.\n";
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Pesan extends CI_Controller {
class Pesan extends MY_Controller {
public function __construct()
{
@@ -0,0 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Professionalreports extends MY_Controller
{
public function __construct(){parent::__construct();$this->requirePermission('reports');$this->load->database();$this->load->library(array('ProfessionalReportService','CompanyContext','JobQueueService'));}
private function filter(){$to=$this->input->get_post('to')?:date('Y-m-d');return array('from'=>$this->input->get_post('from')?:date('Y-m-01',strtotime($to)),'to'=>$to,'account_id'=>(int)$this->input->get_post('account_id'));}
private function reportFilter($code,array$f){if(in_array($code,array('balance_sheet','bank_account_balances'),true))$f['from']='1900-01-01';return$f;}
public function index(){$code=$this->input->get('report')?:'trial_balance';$f=$this->filter();$reportFilters=$this->reportFilter($code,$f);$rows=$this->professionalreportservice->run($code,$reportFilters);$summary=array();if($code==='cash_flow')$summary=$this->professionalreportservice->cashFlowSummary($reportFilters,$rows);elseif($code==='general_ledger')$summary=$this->professionalreportservice->ledgerSummary($reportFilters);elseif($code==='receivable_reconciliation')$summary=$this->professionalreportservice->receivableSummary($reportFilters,$rows);$d=array('active_menu'=>'professional_reports','report'=>$code,'filters'=>$f,'rows'=>$rows,'report_summary'=>$summary,'profit_loss_rows'=>$code==='balance_sheet'?$this->professionalreportservice->run('profit_loss',$reportFilters):array());$d['accounts']=$this->db->where('company_id',$this->companycontext->id())->order_by('kode_akun')->get('accounts')->result();$d['company']=$this->db->get_where('companies',array('id'=>$this->companycontext->id()))->row();$this->load->view('partials/header',$d);$this->load->view('professional_reports/index',$d);$this->load->view('partials/footer');}
public function export($code){$f=$this->reportFilter($code,$this->filter());$rows=$this->professionalreportservice->run($code,$f);$this->output->set_content_type('text/csv')->set_header('Content-Disposition: attachment; filename="'.$code.'-'.$f['to'].'.csv"');$h=fopen('php://output','w');if($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h);}
public function save_filter(){$this->db->insert('saved_report_filters',array('user_id'=>$this->session->userdata('user_id'),'report_code'=>$this->input->post('report_code'),'name'=>$this->input->post('name',true),'filter_json'=>json_encode($this->filter()),'created_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->companycontext->id()));redirect('professionalreports?report='.$this->input->post('report_code'));}
public function snapshot($code){$f=$this->reportFilter($code,$this->filter());$rows=$this->professionalreportservice->run($code,$f);$hash=hash('sha256',json_encode($f));if(!$this->db->get_where('report_snapshots',array('report_code'=>$code,'period_end'=>$f['to'],'filter_hash'=>$hash,'company_id'=>$this->companycontext->id()))->row())$this->db->insert('report_snapshots',array('report_code'=>$code,'period_end'=>$f['to'],'filter_hash'=>$hash,'payload'=>json_encode($rows),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s'),'company_id'=>$this->companycontext->id()));redirect('professionalreports?report='.$code.'&to='.$f['to']);}
public function queue($code){$filters=$this->reportFilter($code,$this->filter());$format=$this->input->post('format')?:'csv';$key='report:'.$this->companycontext->id().':'.$this->session->userdata('user_id').':'.$code.':'.$format.':'.hash('sha256',json_encode($filters));$this->jobqueueservice->enqueue('report_export',array('report_code'=>$code,'format'=>$format,'filters'=>$filters,'requested_by'=>$this->session->userdata('user_id')),$key,'reports',5,3,$this->companycontext->id());$this->session->set_flashdata('success','Export laporan masuk antrean background.');redirect('professionalreports?report='.$code);}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Profile extends MY_Controller
{
public function __construct()
{
parent::__construct();
}
public function index($tab='settings')
{
$tab=$this->input->get('tab',true)?:$tab;if(!in_array($tab,array('settings','devices'),true))$tab='settings';
$user=$this->db->get_where('users',array('id'=>$this->uid()))->row();if(!$user)show_404();
$data=array('active_menu'=>'profile','profile_tab'=>$tab,'user'=>$user,'sessions'=>$this->usersessionservice->activeSessions($user->id),'attempts'=>$this->usersessionservice->attempts($user->id),'active_count'=>$this->usersessionservice->activeCount($user->id));
$this->load->view('partials/header',$data);$this->load->view('profile/index',$data);$this->load->view('partials/footer');
}
public function update_profile()
{
$this->postOnly();$user=$this->user();$newPhoto=null;
try{
$name=trim((string)$this->input->post('nama',true));$email=strtolower(trim((string)$this->input->post('email',true)));
if($name===''||mb_strlen($name)>255)throw new RuntimeException('Nama wajib diisi dan maksimal 255 karakter.');
if($email!==''&&!filter_var($email,FILTER_VALIDATE_EMAIL))throw new RuntimeException('Format email tidak valid.');
if($email!==''&&$this->db->where('email',$email)->where('id !=',$user->id)->get('users')->row())throw new RuntimeException('Email sudah digunakan pengguna lain.');
$newPhoto=$this->uploadPhoto();$update=array('nama'=>$name,'email'=>$email?:null,'profile_updated_at'=>date('Y-m-d H:i:s'));if($newPhoto)$update['profile_photo']=$newPhoto;
$this->db->where('id',$user->id)->update('users',$update);if(!$this->db->trans_status())throw new RuntimeException('Profil gagal disimpan.');
if($newPhoto&&!empty($user->profile_photo))$this->removeOldPhoto($user->profile_photo);
$this->session->set_userdata(array('nama'=>$name,'email'=>$email?:null,'profile_photo'=>$newPhoto?:$user->profile_photo));
log_activity('Profile','update','Memperbarui profil pengguna sendiri','success');$this->session->set_flashdata('success','Profil berhasil diperbarui.');
}catch(Throwable$e){if($newPhoto&&is_file(FCPATH.$newPhoto))@unlink(FCPATH.$newPhoto);$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile');
}
public function update_security()
{
$this->postOnly();$hours=(int)$this->input->post('session_duration_hours');$limit=(int)$this->input->post('max_active_devices');
try{
if(!in_array($hours,array(24,72,168),true))throw new RuntimeException('Durasi session tidak valid.');
if($limit<1||$limit>10)throw new RuntimeException('Batas perangkat harus antara 1 sampai 10.');
$this->db->where('id',$this->uid())->update('users',array('session_duration_hours'=>$hours,'max_active_devices'=>$limit,'profile_updated_at'=>date('Y-m-d H:i:s')));
log_activity('Profile','security_setting','Mengubah durasi session menjadi '.$hours.' jam dan batas perangkat menjadi '.$limit,'success');
$this->session->set_flashdata('success','Pengaturan login berhasil disimpan dan berlaku mulai login berikutnya. Session yang sedang aktif tetap memakai batas waktu awalnya.');
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile');
}
public function update_password()
{
$this->postOnly();$user=$this->user();
try{
$current=(string)$this->input->post('current_password');$new=(string)$this->input->post('new_password');$confirm=(string)$this->input->post('confirm_password');
if(!password_verify($current,$user->password))throw new RuntimeException('Password saat ini tidak sesuai.');
if(strlen($new)<8||!preg_match('/[A-Za-z]/',$new)||!preg_match('/\d/',$new))throw new RuntimeException('Password baru minimal 8 karakter dan harus mengandung huruf serta angka.');
if($new!==$confirm)throw new RuntimeException('Konfirmasi password baru tidak sama.');
if(password_verify($new,$user->password))throw new RuntimeException('Password baru harus berbeda dari password saat ini.');
$this->db->where('id',$user->id)->update('users',array('password'=>password_hash($new,PASSWORD_DEFAULT),'profile_updated_at'=>date('Y-m-d H:i:s')));
$this->usersessionservice->revokeOthers($user->id);log_activity('Profile','password','Mengubah password dan mengakhiri session perangkat lain','success');
$this->session->set_flashdata('success','Password berhasil diubah. Session pada perangkat lain telah diakhiri.');
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile');
}
public function revoke_session($id)
{
$this->postOnly();
try{
$result=$this->usersessionservice->revoke((int)$id,$this->uid());log_activity('Profile','revoke_session','Mengakhiri session perangkat ID '.(int)$id,'success');
if($result['current']){$this->session->sess_destroy();redirect('auth?notice=revoked');return;}
$this->session->set_flashdata('success','Session perangkat berhasil diakhiri.');
}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());}
redirect('profile?tab=devices');
}
private function uploadPhoto()
{
if(empty($_FILES['profile_photo'])||$_FILES['profile_photo']['error']===UPLOAD_ERR_NO_FILE)return null;
$file=$_FILES['profile_photo'];if($file['error']!==UPLOAD_ERR_OK)throw new RuntimeException('Foto profil gagal diunggah.');if((int)$file['size']>2*1024*1024)throw new RuntimeException('Ukuran foto profil maksimal 2 MB.');
$mime=(new finfo(FILEINFO_MIME_TYPE))->file($file['tmp_name']);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp');if(!isset($allowed[$mime]))throw new RuntimeException('Foto profil hanya menerima JPG, PNG, atau WEBP.');
$dir=FCPATH.'uploads/accounting/profiles/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new RuntimeException('Folder foto profil tidak dapat dibuat.');
$relative='uploads/accounting/profiles/'.bin2hex(random_bytes(20)).'.'.$allowed[$mime];if(!move_uploaded_file($file['tmp_name'],FCPATH.$relative))throw new RuntimeException('Foto profil tidak dapat disimpan.');return$relative;
}
private function removeOldPhoto($path){$prefix='uploads/accounting/profiles/';if(strpos((string)$path,$prefix)===0&&is_file(FCPATH.$path))@unlink(FCPATH.$path);}
private function user(){$row=$this->db->get_where('users',array('id'=>$this->uid()))->row();if(!$row)throw new RuntimeException('Pengguna tidak ditemukan.');return$row;}
private function uid(){return(int)$this->session->userdata('user_id');}
private function postOnly(){if(strtoupper($this->input->method())!=='POST')show_404();}
}
@@ -0,0 +1,73 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** Pemeriksaan read-only khusus Retur, Refund, Pengganti, dan Debit Note. */
class Purchaseadjustmentcheck extends CI_Controller
{
public function __construct(){parent::__construct();if(!$this->input->is_cli_request())show_404();}
public function integrity()
{
$checks=array(
'draft_return_without_stock'=>"SELECT COUNT(*) total FROM purchase_returns r JOIN stock_logs l ON l.ref_type='purchase_return' AND l.ref_id=r.id WHERE r.status IN('draft','submitted','approved','reserved')",
'over_returned_receipt'=>"SELECT COUNT(*) total FROM(SELECT l.receipt_line_id,SUM(IF(r.status NOT IN('draft','rejected','cancelled','reversed'),l.qty,0)) returned,MAX(gr.qty) received FROM purchase_return_lines l JOIN purchase_returns r ON r.id=l.purchase_return_id JOIN goods_receipt_lines gr ON gr.id=l.receipt_line_id GROUP BY l.receipt_line_id HAVING returned>received+.0001)x",
'duplicate_return_shipment_line'=>"SELECT COUNT(*) total FROM(SELECT purchase_return_line_id FROM purchase_return_shipment_lines GROUP BY purchase_return_line_id HAVING COUNT(*)>1)x",
'mixed_supplier_return_batch'=>"SELECT COUNT(*) total FROM purchase_return_lines l JOIN purchase_returns r ON r.id=l.purchase_return_id JOIN goods_receipts gr ON gr.id=l.goods_receipt_id WHERE gr.supplier_id<>r.supplier_id",
'pre_invoice_return_overbilled'=>"SELECT COUNT(*) total FROM(SELECT sil.id,sil.qty,grl.qty-COALESCE(SUM(CASE WHEN pr.shipped_at IS NOT NULL AND pr.shipped_at<si.created_at AND pr.status IN('shipped','waiting_supplier','partially_resolved','resolved') THEN prl.shipped_qty ELSE 0 END),0) net_received FROM supplier_invoice_lines sil JOIN supplier_invoices si ON si.id=sil.supplier_invoice_id JOIN goods_receipt_lines grl ON grl.id=sil.receipt_line_id LEFT JOIN purchase_return_lines prl ON prl.receipt_line_id=grl.id LEFT JOIN purchase_returns pr ON pr.id=prl.purchase_return_id GROUP BY sil.id,sil.qty,grl.qty HAVING sil.qty>net_received+.0001)x",
'posted_shipment_without_stock_trace'=>"SELECT COUNT(*) total FROM purchase_return_shipment_lines sl JOIN purchase_return_shipments sh ON sh.id=sl.purchase_return_shipment_id JOIN purchase_return_lines rl ON rl.id=sl.purchase_return_line_id WHERE sh.status='posted' AND (rl.stock_log_id IS NULL OR rl.movement_id IS NULL OR rl.ledger_id IS NULL)",
'replacement_over_return_quantity'=>"SELECT COUNT(*) total FROM(SELECT rl.id,rl.shipped_qty,COALESCE(SUM(pl.qty),0) replacement_qty FROM purchase_return_lines rl LEFT JOIN purchase_replacement_lines pl ON pl.purchase_return_line_id=rl.id LEFT JOIN purchase_replacements p ON p.id=pl.purchase_replacement_id AND p.status NOT IN('cancelled','reversed') GROUP BY rl.id,rl.shipped_qty HAVING replacement_qty>shipped_qty+.0001)x",
'replacement_creates_payable'=>"SELECT COUNT(*) total FROM journals j JOIN journal_details d ON d.journal_id=j.id JOIN system_account_mappings m ON m.mapping_key='accounts_payable' AND m.account_id=d.account_id WHERE j.ref_type='purchase_replacement' AND ABS(d.debit-d.kredit)>.01",
'unit_return_invalid_quantity'=>"SELECT COUNT(*) total FROM purchase_return_lines WHERE tracking_type='UNIT' AND ABS(qty-1)>.0001",
'invalid_return_reservation'=>"SELECT COUNT(*) total FROM item_barcodes WHERE return_reserved_qty<0 OR return_reserved_qty>qty_sisa+.0001",
'refund_over_received'=>"SELECT COUNT(*) total FROM supplier_refund_claims WHERE received_amount>claim_amount+.01 OR ABS(balance-GREATEST(claim_amount-received_amount,0))>.01",
'refund_receipt_sum_mismatch'=>"SELECT COUNT(*) total FROM(SELECT c.id,c.received_amount,COALESCE(SUM(IF(r.status='reversed',0,r.amount)),0) receipt_total FROM supplier_refund_claims c LEFT JOIN supplier_refund_receipts r ON r.supplier_refund_claim_id=c.id GROUP BY c.id,c.received_amount HAVING ABS(received_amount-receipt_total)>.01)x",
'debit_note_negative_invoice'=>"SELECT COUNT(*) total FROM supplier_invoices WHERE balance<-.01",
'unbalanced_adjustment_journal'=>"SELECT COUNT(*) total FROM(SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('purchase_return','supplier_refund_claim','supplier_refund_receipt','supplier_debit_note') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>.01)x",
'duplicate_adjustment_idempotency'=>"SELECT (SELECT COUNT(*) FROM(SELECT idempotency_key FROM purchase_returns WHERE idempotency_key IS NOT NULL GROUP BY idempotency_key HAVING COUNT(*)>1)a)+(SELECT COUNT(*) FROM(SELECT idempotency_key FROM purchase_return_shipments GROUP BY idempotency_key HAVING COUNT(*)>1)b)+(SELECT COUNT(*) FROM(SELECT idempotency_key FROM purchase_replacements GROUP BY idempotency_key HAVING COUNT(*)>1)c)+(SELECT COUNT(*) FROM(SELECT idempotency_key FROM supplier_refund_receipts GROUP BY idempotency_key HAVING COUNT(*)>1)d) total",
'cross_company_return_line'=>"SELECT COUNT(*) total FROM purchase_return_lines l JOIN purchase_returns h ON h.id=l.purchase_return_id WHERE COALESCE(l.company_id,0)<>COALESCE(h.company_id,0)",
'cross_company_adjustment_child'=>"SELECT (SELECT COUNT(*) FROM purchase_return_shipments x JOIN purchase_returns h ON h.id=x.purchase_return_id WHERE COALESCE(x.company_id,0)<>COALESCE(h.company_id,0))+(SELECT COUNT(*) FROM purchase_replacements x JOIN purchase_returns h ON h.id=x.purchase_return_id WHERE COALESCE(x.company_id,0)<>COALESCE(h.company_id,0))+(SELECT COUNT(*) FROM supplier_refund_receipts x JOIN supplier_refund_claims h ON h.id=x.supplier_refund_claim_id WHERE COALESCE(x.company_id,0)<>COALESCE(h.company_id,0)) total",
'orphan_refund_allocation'=>"SELECT COUNT(*) total FROM supplier_refund_allocations a LEFT JOIN supplier_refund_claims c ON c.id=a.supplier_refund_claim_id WHERE c.id IS NULL"
);
$failed=0;$result=array();foreach($checks as$name=>$sql){try{$value=(int)$this->db->query($sql)->row()->total;$result[$name]=array('status'=>$value?'fail':'pass','violations'=>$value);if($value)$failed++;}catch(Throwable$e){$result[$name]=array('status'=>'fail','error'=>$e->getMessage());$failed++;}}
foreach(array('supplier_refund_receivable','purchase_return_adjustment','purchase_price_variance')as$key){$valid=(int)$this->db->select('COUNT(*) total',false)->from('system_account_mappings m')->join('accounts a','a.id=m.account_id')->where(array('m.mapping_key'=>$key,'m.is_active'=>1,'a.is_active'=>1,'a.allow_posting'=>1))->get()->row()->total;$result['mapping_'.$key]=array('status'=>$valid?'pass':'fail','violations'=>$valid?0:1);if(!$valid)$failed++;}
echo json_encode(array('status'=>$failed?'failed':'ok','checks'=>$result),JSON_PRETTY_PRINT).PHP_EOL;if($failed)exit(1);
}
/** Smoke test read-only: memaksa query service dan menu memakai schema aktual. */
public function runtime()
{
$access=$this->db->select('uc.user_id,uc.company_id,u.role_id,r.nama_role')->from('user_companies uc')->join('users u','u.id=uc.user_id')->join('roles r','r.id=u.role_id')->order_by('uc.is_default','DESC')->limit(1)->get()->row();
if(!$access)throw new RuntimeException('User/company untuk runtime QA tidak tersedia.');
$this->session->set_userdata(array('logged_in'=>true,'user_id'=>$access->user_id,'company_id'=>$access->company_id,'role_id'=>$access->role_id,'role'=>$access->nama_role));
$this->load->library(array('CompanyContext','PurchaseAdjustmentService','NavigationService','ProfessionalReportService'));
$sources=$this->purchaseadjustmentservice->returnSources();$barcodes=array();if($sources)$barcodes=$this->purchaseadjustmentservice->barcodesForReceiptLine($sources[0]['receipt_line_id']);
$document=null;$return=$this->db->where('company_id',$access->company_id)->order_by('id','DESC')->get('purchase_returns')->row();if($return)$document=$this->purchaseadjustmentservice->document('purchase_return',$return->id);
$menu=$this->navigationservice->items();$filters=array('from'=>'1900-01-01','to'=>date('Y-m-d'));
$reports=array('purchase_returns'=>count($this->professionalreportservice->run('purchase_returns',$filters)),'supplier_refunds'=>count($this->professionalreportservice->run('supplier_refunds',$filters)),'outstanding_supplier_refunds'=>count($this->professionalreportservice->run('outstanding_supplier_refunds',$filters)),'supplier_debit_notes'=>count($this->professionalreportservice->run('supplier_debit_notes',$filters)));
$netInvoiceReturn=$this->db->query("SELECT COALESCE(SUM(prl.shipped_qty*pol.unit_price*(1+(pol.tax_rate/100))),0) total FROM purchase_return_lines prl JOIN purchase_returns pr ON pr.id=prl.purchase_return_id JOIN goods_receipt_lines grl0 ON grl0.id=prl.receipt_line_id JOIN purchase_order_lines pol ON pol.id=grl0.po_line_id WHERE pol.purchase_order_id=0 AND pr.status IN('shipped','waiting_supplier','partially_resolved','resolved') AND NOT EXISTS (SELECT 1 FROM supplier_invoice_lines sil0 JOIN supplier_invoices si0 ON si0.id=sil0.supplier_invoice_id WHERE sil0.receipt_line_id=prl.receipt_line_id AND si0.created_at<=pr.shipped_at)")->row();
echo json_encode(array('status'=>'ok','company_id'=>(int)$access->company_id,'return_sources'=>count($sources),'first_source_barcodes'=>count($barcodes),'return_document_loaded'=>$return?(bool)$document:null,'net_invoice_return_query'=>is_object($netInvoiceReturn),'menu_items'=>count($menu),'reports'=>$reports),JSON_PRETTY_PRINT).PHP_EOL;
}
public function inventory_differences()
{
$rows=$this->db->query("SELECT l.item_id,l.warehouse_id,l.qty ledger_qty,COALESCE(s.qty,0) stock_log_qty,i.stok item_cache,l.qty-COALESCE(s.qty,0) log_diff,i.stok-l.qty cache_diff FROM(SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id)l LEFT JOIN(SELECT item_id,COALESCE(warehouse_id,0) warehouse_id,SUM(IF(tipe='masuk',qty,-qty)) qty FROM stock_logs GROUP BY item_id,COALESCE(warehouse_id,0))s ON s.item_id=l.item_id AND s.warehouse_id=l.warehouse_id LEFT JOIN items i ON i.id=l.item_id HAVING ABS(log_diff)>.0001 OR ABS(cache_diff)>.0001")->result_array();
foreach($rows as&$row){
$row['item']=$this->db->where('id',(int)$row['item_id'])->get('items')->row_array();
$row['ledger_entries']=$this->db->where(array('item_id'=>(int)$row['item_id'],'warehouse_id'=>(int)$row['warehouse_id']))->order_by('id')->get('inventory_ledger')->result_array();
$row['stock_logs']=$this->db->where(array('item_id'=>(int)$row['item_id'],'warehouse_id'=>(int)$row['warehouse_id']))->order_by('id')->get('stock_logs')->result_array();
$row['barcodes']=$this->db->where(array('item_id'=>(int)$row['item_id'],'warehouse_id'=>(int)$row['warehouse_id']))->order_by('id')->get('item_barcodes')->result_array();
}unset($row);
echo json_encode(array('status'=>$rows?'difference':'ok','rows'=>$rows),JSON_PRETTY_PRINT).PHP_EOL;
}
/** Breakdown read-only untuk investigasi rekonsiliasi nilai persediaan. */
public function accounting_reconciliation()
{
$mapping=$this->db->where(array('mapping_key'=>'inventory','is_active'=>1))->get('system_account_mappings')->row();
if(!$mapping)throw new RuntimeException('Mapping akun persediaan tidak tersedia.');
$ledger=$this->db->query("SELECT document_type,direction,COUNT(*) row_count,SUM(qty) qty,SUM(value) value FROM inventory_ledger GROUP BY document_type,direction ORDER BY document_type,direction")->result_array();
$gl=$this->db->query("SELECT j.ref_type,COUNT(DISTINCT j.id) journal_count,SUM(d.debit-d.kredit) value FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE d.account_id=? AND j.status IN('posted','reversed') GROUP BY j.ref_type ORDER BY j.ref_type",array((int)$mapping->account_id))->result_array();
$openReceipts=$this->db->query("SELECT gr.receipt_no,po.po_no,SUM(grl.qty*pol.unit_price) value,po.paid_amount,po.total FROM goods_receipts gr JOIN purchase_orders po ON po.id=gr.purchase_order_id JOIN goods_receipt_lines grl ON grl.goods_receipt_id=gr.id JOIN purchase_order_lines pol ON pol.id=grl.po_line_id LEFT JOIN supplier_invoice_lines sil ON sil.receipt_line_id=grl.id WHERE gr.status='posted' AND sil.id IS NULL GROUP BY gr.id,gr.receipt_no,po.po_no,po.paid_amount,po.total ORDER BY gr.id")->result_array();
echo json_encode(array('inventory_account_id'=>(int)$mapping->account_id,'ledger_by_source'=>$ledger,'gl_by_source'=>$gl,'received_not_invoiced'=>$openReceipts),JSON_PRETTY_PRINT).PHP_EOL;
}
}
@@ -0,0 +1,61 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Purchasedocuments extends MY_Controller
{
private $types=array(
'purchase_order'=>array('purchase_orders','id','po_no','purchase_order_lines','purchase_order_id'),
'goods_receipt'=>array('goods_receipts','id','receipt_no','goods_receipt_lines','goods_receipt_id'),
'supplier_invoice'=>array('supplier_invoices','id','internal_no','supplier_invoice_lines','supplier_invoice_id'),
'supplier_payment'=>array('supplier_payments','id','payment_no','purchase_payment_sources','supplier_payment_id'),
'purchase_return'=>array('purchase_returns','id','return_no','purchase_return_lines','purchase_return_id'),
'supplier_debit_note'=>array('supplier_debit_notes','id','debit_note_no',null,null),
'purchase_refund'=>array('purchase_refunds','id','refund_no',null,null)
);
private function document($type,$id){
if(!isset($this->types[$type]))return null;$m=$this->types[$type];$items=array();
if($type==='purchase_order'){
$header=$this->db->select('po.*,pol.warehouse_id,s.supplier_code,s.name supplier_name,s.address supplier_address,s.phone supplier_phone,pr.request_no,pr.department,u.nama created_name,a.nama approved_name,w.nama warehouse_name')->from('purchase_orders po')->join('suppliers s','s.id=po.supplier_id')->join('purchase_requests pr','pr.id=po.purchase_request_id','left')->join('users u','u.id=po.created_by','left')->join('users a','a.id=po.approved_by','left')->join('purchase_order_lines pol','pol.purchase_order_id=po.id','left')->join('warehouses w','w.id=pol.warehouse_id','left')->where('po.id',(int)$id)->group_by('po.id')->get()->row_array();
if($header&&$this->db->table_exists('purchase_installment_schedules')){$schedule=$this->db->select('COUNT(*) installment_count,MIN(due_date) first_due_date',false)->where('purchase_order_id',(int)$id)->get('purchase_installment_schedules')->row_array();$header['installment_count']=(int)($schedule['installment_count']??0);$header['first_due_date']=$schedule['first_due_date']??null;}
$lines=$this->db->select('pol.*,k.kode_barang,k.nama nama_barang')->from('purchase_order_lines pol')->join('kode_barang k','k.id=pol.kode_barang_id','left')->where('pol.purchase_order_id',(int)$id)->order_by('pol.id')->get()->result_array();
}elseif($type==='supplier_payment'){
$header=$this->db->select('sp.*,s.supplier_code,s.name supplier_name,s.address supplier_address,po.po_no,po.procurement_method,po.payment_timing,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,po.total po_total,po.paid_amount po_paid_amount,u.nama created_name')->from('supplier_payments sp')->join('suppliers s','s.id=sp.supplier_id','left')->join('purchase_orders po','po.id=sp.purchase_order_id','left')->join('users u','u.id=sp.created_by','left')->where('sp.id',(int)$id)->get()->row_array();
if($header){$journal=$this->db->select('no_ref')->get_where('journals',array('id'=>(int)$header['journal_id']))->row_array();$header['journal_no']=$journal?$journal['no_ref']:null;}
$lines=$this->db->select('pps.amount,a.kode_akun,a.nama_akun')->from('purchase_payment_sources pps')->join('accounts a','a.id=pps.account_id','left')->where('pps.supplier_payment_id',(int)$id)->order_by('pps.id')->get()->result_array();
if(!$lines&&$header&&!empty($header['bank_account_id'])){$account=$this->db->select('kode_akun,nama_akun')->get_where('accounts',array('id'=>(int)$header['bank_account_id']))->row_array();$lines[]=array('amount'=>$header['amount'],'kode_akun'=>$account?$account['kode_akun']:'-','nama_akun'=>$account?$account['nama_akun']:'Kas/Bank');}
if($header&&!empty($header['purchase_order_id']))$items=$this->db->select('pol.description,pol.qty,pol.unit_price,pol.tax_amount,pol.line_total,pol.tracking_type,k.kode_barang,k.nama nama_barang')->from('purchase_order_lines pol')->join('kode_barang k','k.id=pol.kode_barang_id','left')->where('pol.purchase_order_id',(int)$header['purchase_order_id'])->order_by('pol.id')->get()->result_array();
}elseif($type==='goods_receipt'){
$header=$this->db->select('gr.*,po.po_no,po.order_date,po.procurement_method,po.marketplace,po.external_order_no,po.courier,po.tracking_no,po.payment_timing,po.payment_plan,po.payment_status,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,po.total po_total,po.paid_amount po_paid_amount,s.supplier_code,s.name supplier_name,s.address supplier_address,s.payment_term_days,u.nama received_name')->from('goods_receipts gr')->join('purchase_orders po','po.id=gr.purchase_order_id','left')->join('suppliers s','s.id=gr.supplier_id','left')->join('users u','u.id=gr.received_by','left')->where('gr.id',(int)$id)->get()->row_array();
if($header){
$receiptSummary=$this->db->select("COUNT(DISTINCT gr.id) receipt_count,GROUP_CONCAT(DISTINCT CONCAT(gr.receipt_no,' (',DATE_FORMAT(gr.receipt_date,'%d-%m-%Y'),')') ORDER BY gr.receipt_date,gr.id SEPARATOR ', ') receipt_numbers",false)->from('goods_receipts gr')->where('gr.purchase_order_id',(int)$header['purchase_order_id'])->where('gr.status','posted')->get()->row_array();
$header['receipt_count']=(int)($receiptSummary['receipt_count']??0);$header['receipt_numbers']=$receiptSummary['receipt_numbers']??'';
$schedule=$this->db->select('COUNT(*) installment_count,MIN(due_date) first_due_date',false)->where('purchase_order_id',(int)$header['purchase_order_id'])->get('purchase_installment_schedules')->row_array();
$header['installment_count']=(int)($schedule['installment_count']??0);$header['first_due_date']=$schedule['first_due_date']??null;
}
$lines=array();
if($header)$lines=$this->db->select("pol.id po_line_id,pol.qty ordered_qty,pol.received_qty total_received_qty,pol.invoiced_qty,pol.unit_price,pol.tax_rate,pol.line_total,pol.description,pol.tracking_type,pol.qty_per_barcode,k.kode_barang,k.nama nama_barang,w.nama warehouse_name,COALESCE((SELECT SUM(x.qty) FROM goods_receipt_lines x WHERE x.po_line_id=pol.id AND x.goods_receipt_id=".(int)$id."),0) selected_receipt_qty,COALESCE((SELECT SUM(x.qty) FROM goods_receipt_lines x JOIN goods_receipts gx ON gx.id=x.goods_receipt_id WHERE x.po_line_id=pol.id AND gx.status='posted'),0) accumulated_received_qty,COALESCE((SELECT SUM(prl.shipped_qty) FROM purchase_return_lines prl JOIN purchase_returns pr ON pr.id=prl.purchase_return_id JOIN goods_receipt_lines grlr ON grlr.id=prl.receipt_line_id WHERE grlr.po_line_id=pol.id AND pr.status IN('shipped','waiting_supplier','partially_resolved','resolved')),0) accumulated_returned_qty,GREATEST(0,COALESCE((SELECT SUM(x.qty) FROM goods_receipt_lines x JOIN goods_receipts gx ON gx.id=x.goods_receipt_id WHERE x.po_line_id=pol.id AND gx.status='posted'),0)-COALESCE((SELECT SUM(prl.shipped_qty) FROM purchase_return_lines prl JOIN purchase_returns pr ON pr.id=prl.purchase_return_id JOIN goods_receipt_lines grlr ON grlr.id=prl.receipt_line_id WHERE grlr.po_line_id=pol.id AND pr.status IN('shipped','waiting_supplier','partially_resolved','resolved')),0)-COALESCE((SELECT SUM(sil.qty) FROM supplier_invoice_lines sil WHERE sil.po_line_id=pol.id),0)) invoice_available_qty",false)->from('purchase_order_lines pol')->join('kode_barang k','k.id=pol.kode_barang_id','left')->join('warehouses w','w.id=pol.warehouse_id','left')->where('pol.purchase_order_id',(int)$header['purchase_order_id'])->order_by('pol.id')->get()->result_array();
}elseif($type==='supplier_invoice'){
$header=$this->db->select("si.*,s.supplier_code,s.name supplier_name,s.address supplier_address,u.nama created_name,po.po_no,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,po.total po_total,po.paid_amount po_paid_amount,GROUP_CONCAT(DISTINCT gr.receipt_no ORDER BY gr.receipt_date,gr.id SEPARATOR ', ') receipt_no,GROUP_CONCAT(DISTINCT COALESCE(gr.supplier_delivery_no,'-') ORDER BY gr.receipt_date,gr.id SEPARATOR ', ') supplier_delivery_no",false)->from('supplier_invoices si')->join('suppliers s','s.id=si.supplier_id','left')->join('users u','u.id=si.created_by','left')->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.id',(int)$id)->group_by('si.id')->get()->row_array();
if($header)$header['status_label']=array('verified'=>'Perlu Finalisasi','partial'=>'Parsial','paid'=>'Lunas','cancelled'=>'Dibatalkan','reversed'=>'Dibalik')[$header['status']]??$header['status'];
$lines=$this->db->select("SUM(sil.qty) qty,sil.unit_price,SUM(sil.tax_amount) tax_amount,SUM(sil.line_total) line_total,IF(SUM(sil.match_status<>'matched')=0,'matched','mismatch') match_status,pol.description,k.kode_barang,k.nama nama_barang,GROUP_CONCAT(DISTINCT gr.receipt_no ORDER BY gr.receipt_date,gr.id SEPARATOR ', ') receipt_no",false)->from('supplier_invoice_lines sil')->join('purchase_order_lines pol','pol.id=sil.po_line_id','left')->join('kode_barang k','k.id=pol.kode_barang_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('sil.supplier_invoice_id',(int)$id)->group_by('sil.po_line_id,sil.unit_price,pol.description,k.kode_barang,k.nama')->order_by('MIN(sil.id)',null,false)->get()->result_array();
}else{$header=$this->db->get_where($m[0],array($m[1]=>(int)$id))->row_array();$lines=$m[3]?$this->db->get_where($m[3],array($m[4]=>(int)$id))->result_array():array();}
if(!$header)return null;$attachments=$this->db->where(array('entity_type'=>$type,'entity_id'=>(int)$id))->order_by('id','DESC')->get('transaction_attachments')->result_array();return array('type'=>$type,'number'=>$header[$m[2]],'header'=>$header,'lines'=>$lines,'items'=>$items,'attachments'=>$attachments);
}
public function detail($type,$id){$d=$this->document($type,$id);if(!$d)return json_response(false,'Dokumen pembelian tidak ditemukan.',array(),404);foreach($d['attachments']as&$a){$a['preview_url']=base_url('accountingattachments/preview/'.$a['id']);$a['download_url']=$a['preview_url'];$a['actual_download_url']=base_url('accountingattachments/download/'.$a['id']);}return json_response(true,'Detail dokumen pembelian.',$d);}
public function payment_bank_pdf($id)
{
$data=$this->document('supplier_payment',$id);if(!$data)show_404();require_once APPPATH.'third_party/fpdf/fpdf.php';$h=$data['header'];
$principal=(float)($h['amount']??0);$charge=(float)($h['bank_charge_amount']??0);$cashOut=(float)($h['total_cash_out']??0);if($cashOut<=0)$cashOut=$principal+$charge;
$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(15,15,15);$pdf->AddPage();$pdf->SetFont('Arial','B',15);$pdf->Cell(0,8,'BUKTI PEMBAYARAN PEMBELIAN',0,1,'C');$pdf->SetFont('Arial','',9);$pdf->Cell(0,6,$h['payment_no'],0,1,'C');$pdf->Ln(6);
foreach(array('Supplier'=>trim(($h['supplier_code']?:'').' - '.($h['supplier_name']?:'')),'Purchase Order'=>$h['po_no'],'Tanggal Pembayaran'=>$h['payment_date'],'Referensi Bank'=>$h['reference_no'],'Nomor Jurnal'=>$h['journal_no'],'Status'=>strtoupper($h['status']),'Diproses oleh'=>$h['created_name'])as$label=>$value){$pdf->SetFont('Arial','B',9);$pdf->Cell(42,6,$label,0,0);$pdf->SetFont('Arial','',9);$pdf->MultiCell(0,6,': '.($value?:'-'));}
if(!empty($data['items'])){$pdf->Ln(3);$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'BARANG YANG DIBAYAR',0,1);$widths=array(9,32,64,17,25,25);foreach(array('No','Kode','Barang / Spesifikasi','Qty','Harga','Total')as$i=>$label)$pdf->Cell($widths[$i],7,$label,1,0,'C');$pdf->Ln();$pdf->SetFont('Arial','',7);foreach($data['items']as$i=>$item){$pdf->Cell($widths[0],7,$i+1,1,0,'C');$pdf->Cell($widths[1],7,substr($item['kode_barang']?:'-',0,19),1);$pdf->Cell($widths[2],7,substr(($item['nama_barang']?:'').' - '.($item['description']?:''),0,43),1);$pdf->Cell($widths[3],7,number_format($item['qty'],0,',','.'),1,0,'R');$pdf->Cell($widths[4],7,number_format($item['unit_price'],2,',','.'),1,0,'R');$pdf->Cell($widths[5],7,number_format($item['line_total'],2,',','.'),1,1,'R');}}
$pdf->Ln(4);$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'SUMBER KAS / BANK',0,1);$pdf->Cell(12,8,'No',1,0,'C');$pdf->Cell(110,8,'Akun',1,0,'C');$pdf->Cell(50,8,'Nominal Keluar',1,1,'C');$pdf->SetFont('Arial','',9);foreach($data['lines']as$i=>$line){$pdf->Cell(12,8,$i+1,1,0,'C');$pdf->Cell(110,8,substr(($line['kode_akun']?:'-').' - '.($line['nama_akun']?:'-'),0,64),1);$pdf->Cell(50,8,'Rp '.number_format($line['amount'],2,',','.'),1,1,'R');}
$pdf->Ln(4);foreach(array('Pokok Pembayaran'=>$principal,'Biaya Admin Bank'=>$charge,'Total Keluar Kas/Bank'=>$cashOut)as$label=>$value){$pdf->SetFont('Arial',$label==='Total Keluar Kas/Bank'?'B':'',9);$pdf->Cell(122,7,$label,0,0,'R');$pdf->Cell(50,7,'Rp '.number_format($value,2,',','.'),0,1,'R');}
if(!empty($h['notes'])){$pdf->Ln(4);$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'Catatan',0,1);$pdf->SetFont('Arial','',9);$pdf->MultiCell(0,5,$h['notes'],1);}
$pdf->Ln(12);$pdf->SetFont('Arial','',9);$pdf->Cell(0,6,'Finance / Pembayar',0,1,'C');$pdf->Ln(15);$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'('.($h['created_name']?:'-').')',0,1,'C');$pdf->Output('I',$h['payment_no'].'.pdf');
}
public function payment_pdf($id){$d=$this->document('supplier_payment',$id);if(!$d)show_404();require_once APPPATH.'third_party/fpdf/fpdf.php';$h=$d['header'];$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(15,15,15);$pdf->AddPage();$pdf->SetFont('Arial','B',15);$pdf->Cell(0,8,'BUKTI PEMBAYARAN PEMBELIAN',0,1,'C');$pdf->SetFont('Arial','',9);$pdf->Cell(0,6,$h['payment_no'],0,1,'C');$pdf->Ln(6);foreach(array('Supplier'=>trim(($h['supplier_code']?:'').' - '.($h['supplier_name']?:'')),'Purchase Order'=>$h['po_no'],'Tanggal Pembayaran'=>$h['payment_date'],'Referensi'=>$h['reference_no'],'Nomor Jurnal'=>$h['journal_no'],'Status'=>strtoupper($h['status']),'Diproses oleh'=>$h['created_name'])as$label=>$value){$pdf->SetFont('Arial','B',9);$pdf->Cell(42,6,$label,0,0);$pdf->SetFont('Arial','',9);$pdf->MultiCell(0,6,': '.($value?:'-'));}$pdf->Ln(3);if(!empty($d['items'])){$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'BARANG YANG DIBAYAR',0,1);$pdf->SetFont('Arial','B',7);$pdf->Cell(8,7,'No',1,0,'C');$pdf->Cell(30,7,'Kode',1,0,'C');$pdf->Cell(62,7,'Barang / Spesifikasi',1,0,'C');$pdf->Cell(14,7,'Qty',1,0,'C');$pdf->Cell(28,7,'Harga',1,0,'C');$pdf->Cell(30,7,'Total',1,1,'C');$pdf->SetFont('Arial','',7);foreach($d['items']as$i=>$item){$pdf->Cell(8,7,$i+1,1,0,'C');$pdf->Cell(30,7,substr($item['kode_barang']?:'-',0,18),1);$pdf->Cell(62,7,substr(($item['nama_barang']?:'').' - '.($item['description']?:''),0,42),1);$pdf->Cell(14,7,number_format($item['qty'],0,',','.'),1,0,'R');$pdf->Cell(28,7,number_format($item['unit_price'],2,',','.'),1,0,'R');$pdf->Cell(30,7,number_format($item['line_total'],2,',','.'),1,1,'R');}$pdf->Ln(4);}$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'RINCIAN NILAI PURCHASE ORDER',0,1);foreach(array('Subtotal Barang'=>$h['po_subtotal'],'Pajak'=>$h['po_tax_total'],'Ongkos Kirim'=>$h['shipping_cost'],'Asuransi'=>$h['insurance_cost'],'Biaya Layanan'=>$h['service_fee'],'Diskon / Voucher'=>-$h['discount_amount'],'Cashback'=>-$h['cashback_amount'],'Total PO'=>$h['po_total'],'Pembayaran Ini'=>$h['amount'],'Sisa Setelah Pembayaran'=>max(0,$h['po_total']-$h['po_paid_amount']))as$label=>$value){$pdf->SetFont('Arial',in_array($label,array('Total PO','Pembayaran Ini','Sisa Setelah Pembayaran'),true)?'B':'',8);$pdf->Cell(122,5,$label,0,0,'R');$pdf->Cell(50,5,'Rp '.number_format($value,2,',','.'),0,1,'R');}$pdf->Ln(3);$pdf->SetFont('Arial','B',9);$pdf->Cell(12,8,'No',1,0,'C');$pdf->Cell(110,8,'Sumber Kas / Bank',1,0,'C');$pdf->Cell(50,8,'Nominal',1,1,'C');$pdf->SetFont('Arial','',9);foreach($d['lines']as$i=>$line){$pdf->Cell(12,8,$i+1,1,0,'C');$pdf->Cell(110,8,substr(($line['kode_akun']?:'-').' - '.($line['nama_akun']?:'-'),0,64),1);$pdf->Cell(50,8,'Rp '.number_format($line['amount'],2,',','.'),1,1,'R');}$pdf->SetFont('Arial','B',10);$pdf->Cell(122,9,'TOTAL PEMBAYARAN',1,0,'R');$pdf->Cell(50,9,'Rp '.number_format($h['amount'],2,',','.'),1,1,'R');if(!empty($h['notes'])){$pdf->Ln(5);$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'Catatan',0,1);$pdf->SetFont('Arial','',9);$pdf->MultiCell(0,5,$h['notes'],1);}$pdf->Ln(14);$pdf->SetFont('Arial','',9);$pdf->Cell(0,6,'Finance / Pembayar',0,1,'C');$pdf->Ln(16);$pdf->SetFont('Arial','B',9);$pdf->Cell(0,6,'('.($h['created_name']?:'-').')',0,1,'C');$pdf->Output('I',$h['payment_no'].'.pdf');}
public function receipt_pdf($id){$d=$this->document('goods_receipt',$id);if(!$d)show_404();require_once APPPATH.'third_party/fpdf/fpdf.php';$h=$d['header'];$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(15,15,15);$pdf->AddPage();$pdf->SetFont('Arial','B',15);$pdf->Cell(0,8,'BUKTI PENERIMAAN BARANG',0,1,'C');$pdf->SetFont('Arial','',9);$pdf->Cell(0,6,$h['receipt_no'],0,1,'C');$pdf->Ln(6);foreach(array('Supplier'=>trim(($h['supplier_code']?:'').' - '.($h['supplier_name']?:'')),'Purchase Order'=>$h['po_no'],'Tanggal PO'=>$h['order_date'],'Tanggal Diterima'=>$h['receipt_date'],'Surat Jalan / Resi'=>$h['supplier_delivery_no'],'Status'=>strtoupper($h['status']),'Diterima oleh'=>$h['received_name'])as$label=>$value){$pdf->SetFont('Arial','B',9);$pdf->Cell(42,6,$label,0,0);$pdf->SetFont('Arial','',9);$pdf->MultiCell(0,6,': '.($value?:'-'));}$pdf->Ln(3);$widths=array(10,34,65,18,25,20);foreach(array('No','Kode','Barang / Spesifikasi','Qty','Tracking','Gudang')as$i=>$label){$pdf->SetFont('Arial','B',8);$pdf->Cell($widths[$i],8,$label,1,0,'C');}$pdf->Ln();$pdf->SetFont('Arial','',8);$total=0;foreach($d['lines']as$i=>$line){$total+=(float)$line['qty'];$pdf->Cell($widths[0],8,$i+1,1,0,'C');$pdf->Cell($widths[1],8,substr($line['kode_barang']?:'-',0,20),1);$pdf->Cell($widths[2],8,substr(($line['nama_barang']?:'').' - '.($line['description']?:''),0,43),1);$pdf->Cell($widths[3],8,number_format($line['qty'],2,',','.'),1,0,'R');$pdf->Cell($widths[4],8,$line['tracking_type']?:'-',1,0,'C');$pdf->Cell($widths[5],8,substr($line['warehouse_name']?:'-',0,12),1);$pdf->Ln();}$pdf->SetFont('Arial','B',9);$pdf->Cell(109,8,'TOTAL UNIT DITERIMA',1,0,'R');$pdf->Cell(18,8,number_format($total,2,',','.'),1,0,'R');$pdf->Cell(45,8,'',1,1);$pdf->Ln(14);$pdf->SetFont('Arial','',9);$pdf->Cell(86,6,'Petugas Gudang',0,0,'C');$pdf->Cell(86,6,'Pengirim / Supplier',0,1,'C');$pdf->Ln(17);$pdf->SetFont('Arial','B',9);$pdf->Cell(86,6,'('.($h['received_name']?:'-').')',0,0,'C');$pdf->Cell(86,6,'(____________________)',0,1,'C');$pdf->Output('I',$h['receipt_no'].'.pdf');}
public function invoice_pdf($id){$d=$this->document('supplier_invoice',$id);if(!$d)show_404();require_once APPPATH.'third_party/fpdf/fpdf.php';$h=$d['header'];$invoiceTotal=(float)($h['po_total']??$h['total']);$poPaid=max((float)($h['po_paid_amount']??0),(float)$h['paid_amount']);$invoiceBalance=max(0,$invoiceTotal-$poPaid);$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(12,12,12);$pdf->AddPage();$pdf->SetFont('Arial','B',15);$pdf->Cell(0,8,'INVOICE SUPPLIER',0,1,'C');$pdf->SetFont('Arial','',9);$pdf->Cell(0,5,$h['internal_no'].' / '.$h['supplier_invoice_no'],0,1,'C');$pdf->Ln(6);foreach(array('Supplier'=>trim(($h['supplier_code']?:'').' - '.($h['supplier_name']?:'')),'Purchase Order'=>$h['po_no'],'Penerimaan'=>$h['receipt_no'],'Surat Jalan / Resi'=>$h['supplier_delivery_no'],'Tanggal Invoice'=>$h['invoice_date'],'Jatuh Tempo'=>$h['due_date'],'Rencana Pembayaran'=>strtoupper($h['payment_plan']),'Status'=>strtoupper($h['status_label']??$h['status']))as$label=>$value){$pdf->SetFont('Arial','B',8);$pdf->Cell(40,5,$label,0,0);$pdf->SetFont('Arial','',8);$pdf->MultiCell(0,5,': '.($value?:'-'));}$pdf->Ln(3);$widths=array(8,29,48,14,25,22,26);foreach(array('No','Kode','Barang','Qty','Harga','Pajak','Total')as$i=>$label){$pdf->SetFont('Arial','B',7);$pdf->Cell($widths[$i],7,$label,1,0,'C');}$pdf->Ln();$pdf->SetFont('Arial','',7);foreach($d['lines']as$i=>$line){$pdf->Cell($widths[0],7,$i+1,1,0,'C');$pdf->Cell($widths[1],7,substr($line['kode_barang']?:'-',0,17),1);$pdf->Cell($widths[2],7,substr(($line['nama_barang']?:'').' - '.($line['description']?:''),0,32),1);$pdf->Cell($widths[3],7,number_format($line['qty'],2,',','.'),1,0,'R');$pdf->Cell($widths[4],7,number_format($line['unit_price'],2,',','.'),1,0,'R');$pdf->Cell($widths[5],7,number_format($line['tax_amount'],2,',','.'),1,0,'R');$pdf->Cell($widths[6],7,number_format($line['line_total'],2,',','.'),1,1,'R');}$pdf->Ln(3);foreach(array('Subtotal Barang'=>$h['po_subtotal']??$h['subtotal'],'Pajak'=>$h['po_tax_total']??$h['tax_total'],'Ongkir, Asuransi & Layanan'=>(float)($h['shipping_cost']??0)+(float)($h['insurance_cost']??0)+(float)($h['service_fee']??0),'Diskon & Cashback'=>-((float)($h['discount_amount']??0)+(float)($h['cashback_amount']??0)),'Total Invoice'=>$invoiceTotal,'Total Sudah Dibayar'=>$poPaid,'Sisa Tagihan'=>$invoiceBalance)as$label=>$value){$pdf->SetFont('Arial',in_array($label,array('Total Invoice','Total Sudah Dibayar','Sisa Tagihan'),true)?'B':'',8);$pdf->Cell(145,5,$label,0,0,'R');$pdf->Cell(27,5,number_format($value,2,',','.'),0,1,'R');}$pdf->Ln(13);$pdf->SetFont('Arial','',8);$pdf->Cell(86,5,'Dicatat oleh',0,0,'C');$pdf->Cell(86,5,'Disetujui / Diperiksa',0,1,'C');$pdf->Ln(16);$pdf->SetFont('Arial','B',8);$pdf->Cell(86,5,'('.($h['created_name']?:'-').')',0,0,'C');$pdf->Cell(86,5,'(____________________)',0,1,'C');$pdf->Output('I',$h['internal_no'].'.pdf');}
public function pdf($type,$id){$d=$this->document($type,$id);if(!$d)show_404();require_once APPPATH.'third_party/fpdf/fpdf.php';if($type==='purchase_order')return$this->purchaseOrderPdf($d);$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(14,14,14);$pdf->AddPage();$pdf->SetFont('Arial','B',14);$pdf->Cell(0,8,strtoupper(str_replace('_',' ',$type)),0,1,'C');$pdf->SetFont('Arial','',10);$pdf->Cell(0,6,'Nomor: '.$d['number'],0,1,'C');$pdf->Ln(4);foreach($d['header']as$key=>$value){if(is_null($value)||$value===''||in_array($key,array('id','snapshot'),true))continue;$label=ucwords(str_replace('_',' ',$key));$pdf->SetFont('Arial','B',8);$pdf->Cell(45,5,substr($label,0,28),0,0);$pdf->SetFont('Arial','',8);$pdf->MultiCell(0,5,': '.substr((string)$value,0,100));}foreach($d['lines']as$i=>$line){$pdf->SetFont('Arial','B',8);$pdf->Cell(0,5,'Baris '.($i+1),0,1);$pdf->SetFont('Arial','',7);$pdf->MultiCell(0,4,json_encode($line),1);}$pdf->Output('I',$d['number'].'.pdf');}
private function purchaseOrderPdf($d){$h=$d['header'];$method=array('online'=>'Pesanan Online','direct'=>'Pembelian Langsung','supplier'=>'Supplier Biasa');$timing=array('prepaid'=>'Bayar Sebelum Diterima','on_receipt'=>'Bayar Saat Diterima','installment'=>'Cicilan','credit'=>'Kredit/Jatuh Tempo');$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(12,12,12);$pdf->AddPage();$pdf->SetFont('Arial','B',16);$pdf->Cell(0,8,'PURCHASE ORDER',0,1,'C');$pdf->SetFont('Arial','',9);$pdf->Cell(0,5,$h['po_no'],0,1,'C');$pdf->Ln(5);$pairs=array('Supplier'=>($h['supplier_code'].' - '.$h['supplier_name']),'Alamat'=>$h['supplier_address'],'Tanggal PO'=>$h['order_date'],'Estimasi Datang'=>$h['expected_date'],'Sumber PR'=>$h['request_no'],'Departemen'=>$h['department'],'Gudang'=>$h['warehouse_name'],'Metode Pengadaan'=>($method[$h['procurement_method']]??$h['procurement_method']),'Rencana Pembayaran'=>($timing[$h['payment_timing']]??$h['payment_timing']));if($h['procurement_method']==='online'){$pairs['Marketplace']=$h['marketplace'];$pairs['Nomor Pesanan']=$h['external_order_no'];$pairs['Kurir/Resi']=trim(($h['courier']?:'-').' / '.($h['tracking_no']?:'-'));}foreach($pairs as$l=>$v){$pdf->SetFont('Arial','B',8);$pdf->Cell(38,5,$l,0,0);$pdf->SetFont('Arial','',8);$pdf->MultiCell(0,5,': '.($v?:'-'));}$pdf->Ln(3);$widths=array(8,31,55,15,25,15,27);$headers=array('No','Kode','Barang / Spesifikasi','Qty','Harga','Pajak','Subtotal');$pdf->SetFont('Arial','B',7);foreach($headers as$i=>$label)$pdf->Cell($widths[$i],7,$label,1,0,'C');$pdf->Ln();$pdf->SetFont('Arial','',7);foreach($d['lines']as$i=>$l){$pdf->Cell($widths[0],7,$i+1,1,0,'C');$pdf->Cell($widths[1],7,substr($l['kode_barang']?:'-',0,18),1);$pdf->Cell($widths[2],7,substr(($l['nama_barang']?:'').' - '.$l['description'],0,38),1);$pdf->Cell($widths[3],7,number_format($l['qty'],2,',','.'),1,0,'R');$pdf->Cell($widths[4],7,number_format($l['unit_price'],2,',','.'),1,0,'R');$pdf->Cell($widths[5],7,number_format($l['tax_rate'],2,',','.').'%',1,0,'R');$pdf->Cell($widths[6],7,number_format($l['line_total'],2,',','.'),1,0,'R');$pdf->Ln();}$pdf->Ln(3);foreach(array('Subtotal'=>$h['subtotal'],'Pajak'=>$h['tax_total'],'Ongkos Kirim'=>$h['shipping_cost'],'Asuransi'=>$h['insurance_cost'],'Biaya Layanan'=>$h['service_fee'],'Diskon/Voucher'=>-$h['discount_amount'],'Cashback'=>-$h['cashback_amount'],'TOTAL PO'=>$h['total'])as$l=>$v){$pdf->SetFont('Arial',$l==='TOTAL PO'?'B':'',8);$pdf->Cell(145,5,$l,0,0,'R');$pdf->Cell(27,5,number_format($v,2,',','.'),0,1,'R');}$pdf->Ln(12);$pdf->SetFont('Arial','',8);$pdf->Cell(86,5,'Dibuat oleh',0,0,'C');$pdf->Cell(86,5,'Disetujui oleh',0,1,'C');$pdf->Ln(16);$pdf->SetFont('Arial','B',8);$pdf->Cell(86,5,'('.($h['created_name']?:'-').')',0,0,'C');$pdf->Cell(86,5,'('.($h['approved_name']?:'-').')',0,1,'C');$pdf->Output('I',$h['po_no'].'.pdf');}
}
+205
View File
@@ -0,0 +1,205 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Purchasereturns extends MY_Controller
{
private $storedPaths = array();
public function __construct()
{
parent::__construct();
$this->load->library('PurchaseAdjustmentService');
}
public function index()
{
$this->requireAnyView();
$company=$this->companycontext->id();
$master=$this->session->userdata('role')==='Admin';
$viewReturns=$master||check_permission('purchase_returns','can_view');
$viewRefunds=$master||check_permission('purchase_refunds','can_view');
$viewDebitNotes=$master||check_permission('supplier_debit_notes','can_view');
$this->db->select('r.*,s.supplier_code,s.name supplier_name,po.po_no,u.nama created_name')->from('purchase_returns r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->join('users u','u.id=r.created_by','left');
if($this->db->field_exists('company_id','purchase_returns'))$this->db->where('r.company_id',$company);
$returns=$this->db->order_by('r.id','DESC')->limit(250)->get()->result();
$this->db->select('r.*,s.supplier_code,s.name supplier_name,po.po_no,si.internal_no')->from('supplier_refund_claims r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id','left')->join('supplier_invoices si','si.id=r.supplier_invoice_id','left')->where('r.company_id',$company);
$refunds=$this->db->order_by('r.id','DESC')->limit(250)->get()->result();
$this->db->select('d.*,s.supplier_code,s.name supplier_name,si.internal_no,po.po_no')->from('supplier_debit_notes d')->join('suppliers s','s.id=d.supplier_id')->join('supplier_invoices si','si.id=d.supplier_invoice_id','left')->join('purchase_orders po','po.id=d.purchase_order_id','left');
if($this->db->field_exists('company_id','supplier_debit_notes'))$this->db->where('d.company_id',$company);
$debitNotes=$this->db->order_by('d.id','DESC')->limit(250)->get()->result();
$replacements=$this->db->select('r.*,pr.return_no,s.name supplier_name,w.nama warehouse_name')->from('purchase_replacements r')->join('purchase_returns pr','pr.id=r.purchase_return_id')->join('suppliers s','s.id=r.supplier_id')->join('warehouses w','w.id=r.warehouse_id','left')->where('r.company_id',$company)->order_by('r.id','DESC')->limit(250)->get()->result();
$shipments=$this->db->select('sh.*,pr.return_no,s.name supplier_name,po.po_no')->from('purchase_return_shipments sh')->join('purchase_returns pr','pr.id=sh.purchase_return_id')->join('suppliers s','s.id=pr.supplier_id')->join('purchase_orders po','po.id=pr.purchase_order_id','left')->where('sh.company_id',$company)->order_by('sh.id','DESC')->limit(250)->get()->result();
$refundReceipts=$this->db->select('rr.*,rc.refund_no,s.name supplier_name,a.kode_akun,a.nama_akun')->from('supplier_refund_receipts rr')->join('supplier_refund_claims rc','rc.id=rr.supplier_refund_claim_id')->join('suppliers s','s.id=rc.supplier_id')->join('accounts a','a.id=rr.bank_account_id','left')->where('rr.company_id',$company)->order_by('rr.id','DESC')->limit(250)->get()->result();
$this->db->from('suppliers')->group_start()->where('is_active',1)->or_where("EXISTS(SELECT 1 FROM purchase_returns pr WHERE pr.supplier_id=suppliers.id)",null,false)->group_end();if($this->db->field_exists('company_id','suppliers'))$this->db->where('company_id',$company);$suppliers=$this->db->order_by('name')->get()->result();
$this->db->from('accounts')->where(array('is_active'=>1,'allow_posting'=>1))->group_start()->like('nama_akun','kas')->or_like('nama_akun','bank')->group_end();if($this->db->field_exists('company_id','accounts'))$this->db->where('company_id',$company);$cashAccounts=$this->db->order_by('kode_akun')->get()->result();
$this->db->from('warehouses');if($this->db->field_exists('company_id','warehouses'))$this->db->where('company_id',$company);$warehouses=$this->db->order_by('nama')->get()->result();
$this->db->select('si.id,si.internal_no,si.supplier_invoice_no,si.supplier_id,si.status,si.total,si.paid_amount,si.balance,s.name supplier_name,po.id po_id,po.po_no')->from('supplier_invoices si')->join('suppliers s','s.id=si.supplier_id')->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_in('si.status',array('partial','paid'));if($this->db->field_exists('company_id','supplier_invoices'))$this->db->where('si.company_id',$company);$invoices=$this->db->group_by('si.id')->order_by('si.id','DESC')->get()->result();
$refundSources=$viewRefunds?$this->refundSources($company):array();
if($this->input->get('action',true)==='history')$this->filterRelatedDocuments($returns,$refunds,$debitNotes,$replacements,$shipments,$refundReceipts);
if(!$viewReturns){$returns=array();$replacements=array();$shipments=array();}
if(!$viewRefunds){$refunds=array();$refundReceipts=array();}
if(!$viewDebitNotes)$debitNotes=array();
$badges=$this->badges($returns,$refunds,$debitNotes);
$section=$this->input->get('section',true)?:($this->input->get('action',true)==='history'?'history':'summary');$validSections=array('summary','history');if($viewReturns)$validSections=array_merge($validSections,array('return','shipment','replacement'));if($viewRefunds)$validSections[]='refund';if($viewDebitNotes)$validSections[]='debit_note';if(!in_array($section,$validSections,true))$section='summary';
$data=array('active_menu'=>'purchase_returns','active_section'=>$section,'returns'=>$returns,'refunds'=>$refunds,'debit_notes'=>$debitNotes,'replacements'=>$replacements,'shipments'=>$shipments,'refund_receipts'=>$refundReceipts,'suppliers'=>$suppliers,'cash_accounts'=>$cashAccounts,'warehouses'=>$warehouses,'invoices'=>$invoices,'return_sources'=>$viewReturns?$this->purchaseadjustmentservice->returnSources():array(),'refund_sources'=>$refundSources,'badges'=>$badges,'prefill'=>array('action'=>$this->input->get('action',true),'category'=>$this->input->get('category',true),'receipt_id'=>(int)$this->input->get('receipt_id'),'receipt_line_id'=>(int)$this->input->get('receipt_line_id'),'po_id'=>(int)$this->input->get('po_id'),'invoice_id'=>(int)$this->input->get('invoice_id'),'payment_id'=>(int)$this->input->get('payment_id'),'return_id'=>(int)$this->input->get('return_id'),'focus'=>(int)$this->input->get('focus'),'focus_type'=>$this->input->get('focus_type',true)));
$this->load->view('partials/header',$data);$this->load->view('purchase_returns/index',$data);$this->load->view('partials/footer');
}
public function source_barcodes($receiptLineId)
{
$this->requirePermission('purchase_returns','can_view');
return json_response(true,'Barcode/batch tersedia.',array('barcodes'=>$this->purchaseadjustmentservice->barcodesForReceiptLine((int)$receiptLineId)));
}
public function detail($type,$id)
{
$this->requireDocumentPermission($type,'can_view');$document=$this->purchaseadjustmentservice->document($type,(int)$id);if(!$document)return json_response(false,'Dokumen Retur & Refund tidak ditemukan.',array(),404);foreach($document['attachments']as&$file){$file['preview_url']=base_url('accountingattachments/preview/'.$file['id']);$file['download_url']=base_url('accountingattachments/download/'.$file['id']);}return json_response(true,'Detail dokumen.',$document);
}
public function create_return()
{
$this->requirePermission('purchase_returns','can_create');
$receiptIds=(array)$this->input->post('receipt_line_id');$barcodeIds=(array)$this->input->post('barcode_id');$quantities=(array)$this->input->post('qty');$lines=array();foreach($receiptIds as$i=>$receiptId)$lines[]=array('receipt_line_id'=>$receiptId,'barcode_id'=>$barcodeIds[$i]??0,'qty'=>$this->amount($quantities[$i]??0));
return$this->action(function()use($lines){return$this->uploadedTransaction('return_documents','purchase_return',function()use($lines){return$this->purchaseadjustmentservice->createReturn(array('return_date'=>$this->input->post('return_date'),'reason'=>$this->input->post('reason',true),'problem_category'=>$this->input->post('problem_category'),'requested_resolution'=>$this->input->post('requested_resolution'),'notes'=>$this->input->post('notes',true),'idempotency_key'=>$this->input->post('idempotency_key',true)),$lines,$this->uid());});},'Draft pengajuan retur multi-item berhasil dibuat.','return');
}
public function submit_return($id){$this->requirePermission('purchase_returns','can_update');return$this->action(fn()=>$this->purchaseadjustmentservice->submitReturn($id,$this->uid()),'Pengajuan retur dikirim untuk persetujuan.','return');}
public function approve_return($id){$this->requirePermission('purchase_returns','can_approve');return$this->action(fn()=>$this->purchaseadjustmentservice->approveReturn($id,$this->uid(),$this->roleId()),'Retur disetujui dan barang telah direservasi.','shipment');}
public function reject_return($id){$this->requirePermission('purchase_returns','can_approve');return$this->action(fn()=>$this->purchaseadjustmentservice->rejectReturn($id,$this->input->post('reason',true),$this->uid(),$this->roleId()),'Pengajuan retur ditolak.','return');}
public function cancel_return($id){$this->requirePermission('purchase_returns','can_update');return$this->action(fn()=>$this->purchaseadjustmentservice->cancelReturn($id,$this->input->post('reason',true),$this->uid()),'Retur dibatalkan tanpa menghapus histori.','return');}
public function reverse_return($id){$this->requirePermission('purchase_returns','can_post');return$this->action(fn()=>$this->purchaseadjustmentservice->reverseReturn($id,$this->input->post('date'),$this->input->post('reason',true),$this->uid()),'Retur berhasil direversal dan stok dikembalikan secara resmi.','history');}
public function post_shipment($id)
{
$this->requirePermission('purchase_returns','can_post');
return$this->action(function()use($id){return$this->uploadedTransaction('shipment_documents','purchase_return_shipment',function()use($id){return$this->purchaseadjustmentservice->postReturnShipment($id,array('shipment_date'=>$this->input->post('shipment_date'),'courier'=>$this->input->post('courier',true),'tracking_no'=>$this->input->post('tracking_no',true),'notes'=>$this->input->post('notes',true),'idempotency_key'=>$this->input->post('idempotency_key',true),'attachment_pending'=>1),$this->uid());});},'Pengeluaran dan pengiriman retur berhasil diposting satu kali.','shipment');
}
public function receive_replacement($id)
{
$this->requirePermission('purchase_returns','can_post');$quantities=(array)$this->input->post('qty');$serials=(array)$this->input->post('serial_numbers',true);
return$this->action(function()use($id,$quantities,$serials){return$this->uploadedTransaction('replacement_documents','purchase_replacement',function()use($id,$quantities,$serials){return$this->purchaseadjustmentservice->receiveReplacement($id,array('receipt_date'=>$this->input->post('receipt_date'),'supplier_delivery_no'=>$this->input->post('supplier_delivery_no',true),'warehouse_id'=>$this->input->post('warehouse_id'),'idempotency_key'=>$this->input->post('idempotency_key',true),'attachment_pending'=>1),array_map(array($this,'amount'),$quantities),$serials,$this->uid());});},'Barang pengganti berhasil diterima tanpa membentuk hutang baru.','replacement');
}
public function create_refund()
{
$this->requirePermission('purchase_refunds','can_create');$types=(array)$this->input->post('source_type');$ids=(array)$this->input->post('source_id');$amounts=(array)$this->input->post('allocation_amount');$allocations=array();foreach($types as$i=>$type)$allocations[]=array('source_type'=>$type,'source_id'=>$ids[$i]??0,'amount'=>$this->amount($amounts[$i]??0));
return$this->action(function()use($allocations){return$this->uploadedTransaction('refund_documents','supplier_refund',function()use($allocations){return$this->purchaseadjustmentservice->createRefund(array('claim_date'=>$this->input->post('claim_date'),'expected_date'=>$this->input->post('expected_date'),'reason'=>$this->input->post('reason',true),'source_category'=>$this->input->post('source_category'),'target_account_id'=>$this->input->post('target_account_id'),'idempotency_key'=>$this->input->post('idempotency_key',true)),$allocations,$this->uid());});},'Draft pengajuan refund berhasil dibuat.','refund');
}
public function submit_refund($id){$this->requirePermission('purchase_refunds','can_update');return$this->action(fn()=>$this->purchaseadjustmentservice->submitRefund($id,$this->uid()),'Pengajuan refund dikirim untuk persetujuan.','refund');}
public function approve_refund($id){$this->requirePermission('purchase_refunds','can_approve');return$this->action(fn()=>$this->purchaseadjustmentservice->approveRefund($id,$this->uid(),$this->roleId()),'Refund disetujui dan menunggu dana supplier.','refund');}
public function reject_refund($id){$this->requirePermission('purchase_refunds','can_approve');return$this->action(fn()=>$this->purchaseadjustmentservice->rejectRefund($id,$this->input->post('reason',true),$this->uid(),$this->roleId()),'Pengajuan refund ditolak.','refund');}
public function receive_refund($id){$this->requirePermission('purchase_refunds','can_post');return$this->action(function()use($id){return$this->uploadedTransaction('refund_receipt_documents','supplier_refund_receipt',function()use($id){return$this->purchaseadjustmentservice->receiveRefund($id,array('receipt_date'=>$this->input->post('receipt_date'),'bank_account_id'=>$this->input->post('bank_account_id'),'amount'=>$this->amount($this->input->post('amount')),'reference_no'=>$this->input->post('reference_no',true),'notes'=>$this->input->post('notes',true),'idempotency_key'=>$this->input->post('idempotency_key',true),'attachment_pending'=>1),$this->uid());});},'Penerimaan dana refund berhasil diposting.','refund');}
public function reconcile_refund($id){$this->requirePermission('purchase_refunds','can_post');return$this->action(fn()=>$this->purchaseadjustmentservice->reconcileRefund($id,$this->uid()),'Refund selesai direkonsiliasi.','refund');}
public function cancel_refund($id){$this->requirePermission('purchase_refunds','can_update');return$this->action(fn()=>$this->purchaseadjustmentservice->cancelRefund($id,$this->input->post('reason',true),$this->uid()),'Klaim refund dibatalkan tanpa menghapus histori.','refund');}
public function reverse_refund_receipt($id){$this->requirePermission('purchase_refunds','can_post');return$this->action(fn()=>$this->purchaseadjustmentservice->reverseRefundReceipt($id,$this->input->post('date'),$this->input->post('reason',true),$this->uid()),'Penerimaan dana refund berhasil direversal.','history');}
public function create_debit_note()
{
$this->requirePermission('supplier_debit_notes','can_create');
return$this->action(function(){return$this->uploadedTransaction('debit_note_documents','supplier_debit_note',function(){return$this->purchaseadjustmentservice->createDebitNote(array('supplier_invoice_id'=>$this->input->post('supplier_invoice_id'),'purchase_return_id'=>$this->input->post('purchase_return_id'),'note_date'=>$this->input->post('note_date'),'correction_type'=>$this->input->post('correction_type'),'base_amount'=>$this->amount($this->input->post('base_amount')),'tax_amount'=>$this->amount($this->input->post('tax_amount')),'reason'=>$this->input->post('reason',true),'idempotency_key'=>$this->input->post('idempotency_key',true)),$this->uid());});},'Draft Debit Note berhasil dibuat.','debit_note');
}
public function post_debit_note($id){$this->requirePermission('supplier_debit_notes','can_post');return$this->action(fn()=>$this->purchaseadjustmentservice->postDebitNote($id,$this->uid()),'Debit Note berhasil diposting dan saldo hutang diperbarui.','debit_note');}
public function reverse_debit_note($id){$this->requirePermission('supplier_debit_notes','can_post');return$this->action(fn()=>$this->purchaseadjustmentservice->reverseDebitNote($id,$this->input->post('date'),$this->input->post('reason',true),$this->uid()),'Debit Note berhasil direversal dan saldo invoice dipulihkan.','history');}
public function pdf($type,$id)
{
$this->requireDocumentPermission($type,'can_export');
$document=$this->purchaseadjustmentservice->document($type,(int)$id);if(!$document)show_404();
require_once APPPATH.'third_party/fpdf/fpdf.php';
$h=$document['header'];
$titles=array('purchase_return'=>'PENGAJUAN RETUR PEMBELIAN','return_resolution'=>'RINGKASAN PENYELESAIAN RETUR','purchase_return_shipment'=>'SURAT PENGIRIMAN RETUR','purchase_replacement'=>'BUKTI BARANG PENGGANTI','supplier_refund'=>'PENGAJUAN / PENYELESAIAN REFUND','supplier_refund_receipt'=>'BUKTI PENERIMAAN REFUND','supplier_debit_note'=>'DEBIT NOTE SUPPLIER');
$numbers=array('purchase_return'=>'return_no','return_resolution'=>'return_no','purchase_return_shipment'=>'shipment_no','purchase_replacement'=>'replacement_no','supplier_refund'=>'refund_no','supplier_refund_receipt'=>'receipt_no','supplier_debit_note'=>'debit_note_no');
$company=$this->db->get_where('companies',array('id'=>$this->companycontext->id()))->row();
$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(12,12,12);$pdf->SetAutoPageBreak(true,14);$pdf->AddPage();
$pdf->SetFont('Arial','B',12);$pdf->Cell(0,6,$this->pdfText($company?($company->legal_name?:$company->name):'Perusahaan'),0,1,'C');
if($company&&!empty($company->address)){$pdf->SetFont('Arial','',7);$pdf->MultiCell(0,4,$this->pdfText($company->address),0,'C');}
$pdf->SetDrawColor(190,198,210);$pdf->Line(12,$pdf->GetY()+2,198,$pdf->GetY()+2);$pdf->Ln(5);
$pdf->SetFont('Arial','B',14);$pdf->Cell(0,8,$titles[$type]??'DOKUMEN PENYESUAIAN PEMBELIAN',0,1,'C');
$numberKey=$numbers[$type]??null;$documentNo=$numberKey?($h[$numberKey]??'-'):'-';$pdf->SetFont('Arial','',9);$pdf->Cell(0,5,$this->pdfText($documentNo),0,1,'C');$pdf->Ln(5);
foreach($this->pdfPairs($type,$h)as$label=>$value){$pdf->SetFont('Arial','B',8);$pdf->Cell(43,5,$this->pdfText($label),0,0);$pdf->SetFont('Arial','',8);$pdf->MultiCell(0,5,': '.$this->pdfText($value?:'-'));}
if($document['lines']){$pdf->Ln(3);$pdf->SetFillColor(242,245,249);$pdf->SetFont('Arial','B',8);foreach(array(10=>'No',35=>'Sumber / Kode',67=>'Barang / Keterangan',20=>'Qty',40=>'Nilai')as$width=>$label)$pdf->Cell($width,7,$label,1,0,'C',true);$pdf->Ln();$pdf->SetFont('Arial','',7);foreach($document['lines']as$i=>$line){$source=$line['receipt_no']??($line['source_type']??($line['kode_barang']??'-'));$description=$line['description']??($line['item_name']??'-');$qty=(float)($line['qty']??0);$value=$line['total_amount']??($line['allocated_amount']??($line['amount']??0));$qtyText=abs($qty-round($qty))<.0001?number_format($qty,0,',','.'):number_format($qty,4,',','.');$pdf->Cell(10,7,$i+1,1,0,'C');$pdf->Cell(35,7,substr($this->pdfText($source),0,21),1);$pdf->Cell(67,7,substr($this->pdfText($description),0,42),1);$pdf->Cell(20,7,$qtyText,1,0,'R');$pdf->Cell(40,7,'Rp '.number_format((float)$value,2,',','.'),1,1,'R');}}
$totalKeys=array('supplier_refund'=>'claim_amount','supplier_refund_receipt'=>'amount','supplier_debit_note'=>'amount','purchase_return'=>'amount','return_resolution'=>'amount');$totalKey=$totalKeys[$type]??null;$documentTotal=$totalKey&&isset($h[$totalKey])?(float)$h[$totalKey]:null;if($documentTotal!==null){$pdf->SetFont('Arial','B',8);$pdf->Cell(132,7,'TOTAL DOKUMEN',1,0,'R');$pdf->Cell(40,7,'Rp '.number_format($documentTotal,2,',','.'),1,1,'R');}
if(!empty($document['history'])){$pdf->Ln(4);$pdf->SetFont('Arial','B',8);$pdf->Cell(0,6,'RIWAYAT STATUS',0,1);$pdf->SetFont('Arial','',7);foreach($document['history']as$history)$pdf->MultiCell(0,5,$this->pdfText(($history['created_at']??'').' | '.($history['from_status']??'-').' -> '.($history['to_status']??'-').' | '.($history['user_name']??'-').' | '.($history['notes']??'')),1);}
$pdf->Ln(12);$pdf->SetFont('Arial','',8);$pdf->Cell(58,5,'Dibuat / Diajukan',0,0,'C');$pdf->Cell(58,5,'Disetujui / Diproses',0,0,'C');$pdf->Cell(58,5,'Supplier',0,1,'C');$pdf->Ln(15);$pdf->Cell(58,5,'('.$this->pdfText($h['created_name']??($h['received_name']??'-')).')',0,0,'C');$pdf->Cell(58,5,'('.$this->pdfText($h['approved_name']??($h['posted_name']??'-')).')',0,0,'C');$pdf->Cell(58,5,'(________________)',0,1,'C');
$pdf->Output('I',$documentNo.'.pdf');
}
private function refundSources($company)
{
$out=array();
$poClaims="COALESCE((SELECT SUM(rc0.claim_amount) FROM supplier_refund_claims rc0 WHERE rc0.purchase_order_id=po.id AND rc0.status NOT IN('rejected','cancelled','reversed')),0)";
$this->db->select("'purchase_order' source_type,po.id source_id,po.id purchase_order_id,po.supplier_id,po.po_no number,GREATEST(0,po.paid_amount-po.transit_cleared_amount-{$poClaims}) eligible_amount,s.name supplier_name",false)->from('purchase_orders po')->join('suppliers s','s.id=po.supplier_id')->where("po.paid_amount-po.transit_cleared_amount-{$poClaims} > 0",null,false)->where("NOT EXISTS(SELECT 1 FROM supplier_payments sp0 WHERE sp0.purchase_order_id=po.id AND sp0.status='posted')",null,false)->where_not_in('po.status',array('cancelled','closed'));if($this->db->field_exists('company_id','purchase_orders'))$this->db->where('po.company_id',$company);$out=array_merge($out,$this->db->get()->result_array());
$claimed="COALESCE((SELECT SUM(ra.allocated_amount) FROM supplier_refund_allocations ra JOIN supplier_refund_claims rc ON rc.id=ra.supplier_refund_claim_id WHERE ra.source_type='supplier_payment' AND ra.source_id=p.id AND rc.status NOT IN('rejected','cancelled','reversed')),0)";
$poCommitted="COALESCE((SELECT SUM(rc0.claim_amount) FROM supplier_refund_claims rc0 WHERE rc0.purchase_order_id=p.purchase_order_id AND rc0.status NOT IN('rejected','cancelled','reversed')),0)";
$eligible="CASE WHEN p.purchase_order_id IS NOT NULL THEN LEAST(p.amount,GREATEST(0,po.paid_amount-po.transit_cleared_amount-{$poCommitted}-COALESCE((SELECT SUM(p2.amount) FROM supplier_payments p2 WHERE p2.purchase_order_id=p.purchase_order_id AND p2.status='posted' AND p2.id>p.id),0))) ELSE GREATEST(0,p.amount-COALESCE((SELECT SUM(spa.amount) FROM supplier_payment_allocations spa WHERE spa.supplier_payment_id=p.id),0)-{$claimed}) END";
$this->db->select("'supplier_payment' source_type,p.id source_id,p.purchase_order_id,p.supplier_id,p.payment_no number,GREATEST(0,{$eligible}) eligible_amount,s.name supplier_name",false)->from('supplier_payments p')->join('suppliers s','s.id=p.supplier_id')->join('purchase_orders po','po.id=p.purchase_order_id','left')->where('p.status','posted')->where("{$eligible} > 0",null,false);if($this->db->field_exists('company_id','supplier_payments'))$this->db->where('p.company_id',$company);$out=array_merge($out,$this->db->get()->result_array());
$claimed="COALESCE((SELECT SUM(ra.allocated_amount) FROM supplier_refund_allocations ra JOIN supplier_refund_claims rc ON rc.id=ra.supplier_refund_claim_id WHERE ra.source_type='purchase_return' AND ra.source_id=r.id AND rc.status NOT IN('rejected','cancelled','reversed')),0)";
$returnEligible="LEAST(GREATEST(0,r.amount-{$claimed}),GREATEST(0,po.paid_amount-po.transit_cleared_amount-COALESCE((SELECT SUM(rc1.claim_amount) FROM supplier_refund_claims rc1 WHERE rc1.purchase_order_id=r.purchase_order_id AND rc1.status NOT IN('rejected','cancelled','reversed')),0)))";
$this->db->select("'purchase_return' source_type,r.id source_id,r.purchase_order_id,r.supplier_id,r.return_no number,{$returnEligible} eligible_amount,s.name supplier_name",false)->from('purchase_returns r')->join('suppliers s','s.id=r.supplier_id')->join('purchase_orders po','po.id=r.purchase_order_id')->where_in('r.status',array('shipped','waiting_supplier','partially_resolved'))->where("{$returnEligible} > 0",null,false);if($this->db->field_exists('company_id','purchase_returns'))$this->db->where('r.company_id',$company);return array_merge($out,$this->db->get()->result_array());
}
private function badges($returns,$refunds,$notes)
{
$result=array('return'=>0,'shipment'=>0,'replacement'=>0,'refund'=>0,'debit_note'=>0,'attention'=>0);foreach($returns as$r){if(in_array($r->status,array('draft','submitted','approved'),true))$result['return']++;if($r->status==='reserved')$result['shipment']++;if(in_array($r->status,array('shipped','waiting_supplier','partially_resolved'),true))$result['replacement']++;}foreach($refunds as$r)if(!in_array($r->status,array('reconciled','cancelled','reversed','rejected'),true))$result['refund']++;foreach($notes as$n)if($n->status==='draft')$result['debit_note']++;$result['attention']=$result['return']+$result['shipment']+$result['replacement']+$result['refund']+$result['debit_note'];return$result;
}
private function filterRelatedDocuments(&$returns,&$refunds,&$notes,&$replacements,&$shipments,&$refundReceipts)
{
$focus=(int)$this->input->get('focus');$focusType=$this->input->get('focus_type',true);
if($focus&&in_array($focusType,array('purchase_return','supplier_refund','supplier_debit_note'),true)){
if($focusType==='purchase_return'){$returns=array_values(array_filter($returns,fn($r)=>(int)$r->id===$focus));$refunds=array_values(array_filter($refunds,fn($r)=>(int)$r->purchase_return_id===$focus));$notes=array_values(array_filter($notes,fn($r)=>(int)$r->purchase_return_id===$focus));$replacements=array_values(array_filter($replacements,fn($r)=>(int)$r->purchase_return_id===$focus));$shipments=array_values(array_filter($shipments,fn($r)=>(int)$r->purchase_return_id===$focus));$refundIds=array_map(fn($r)=>(int)$r->id,$refunds);$refundReceipts=array_values(array_filter($refundReceipts,fn($r)=>in_array((int)$r->supplier_refund_claim_id,$refundIds,true)));}
elseif($focusType==='supplier_refund'){$returns=array();$notes=array();$replacements=array();$shipments=array();$refunds=array_values(array_filter($refunds,fn($r)=>(int)$r->id===$focus));$refundReceipts=array_values(array_filter($refundReceipts,fn($r)=>(int)$r->supplier_refund_claim_id===$focus));}
else{$returns=array();$refunds=array();$replacements=array();$shipments=array();$refundReceipts=array();$notes=array_values(array_filter($notes,fn($r)=>(int)$r->id===$focus));}
return;
}
$po=(int)$this->input->get('po_id');$receipt=(int)$this->input->get('receipt_id');$invoice=(int)$this->input->get('invoice_id');$payment=(int)$this->input->get('payment_id');
if(!$po&&!$receipt&&!$invoice&&!$payment)return;
$returnIds=array();foreach($returns as$r)if(($po&&(int)$r->purchase_order_id===$po)||($receipt&&(int)$r->goods_receipt_id===$receipt))$returnIds[]=(int)$r->id;
if($invoice){$rows=$this->db->select('DISTINCT prl.purchase_return_id id',false)->from('purchase_return_lines prl')->join('supplier_invoice_lines sil','sil.receipt_line_id=prl.receipt_line_id')->where('sil.supplier_invoice_id',$invoice)->get()->result();foreach($rows as$row)$returnIds[]=(int)$row->id;}
$claimIds=array();if($payment){foreach($this->db->select('supplier_refund_claim_id id')->get_where('supplier_refund_allocations',array('source_type'=>'supplier_payment','source_id'=>$payment))->result()as$row)$claimIds[]=(int)$row->id;}
$returns=array_values(array_filter($returns,function($r)use($returnIds){return in_array((int)$r->id,$returnIds,true);}));
$refunds=array_values(array_filter($refunds,function($r)use($po,$invoice,$claimIds,$returnIds){return($po&&(int)$r->purchase_order_id===$po)||($invoice&&(int)$r->supplier_invoice_id===$invoice)||in_array((int)$r->id,$claimIds,true)||in_array((int)$r->purchase_return_id,$returnIds,true);}));
$notes=array_values(array_filter($notes,function($r)use($po,$invoice,$returnIds){return($po&&(int)$r->purchase_order_id===$po)||($invoice&&(int)$r->supplier_invoice_id===$invoice)||in_array((int)$r->purchase_return_id,$returnIds,true);}));
$replacements=array_values(array_filter($replacements,function($r)use($returnIds){return in_array((int)$r->purchase_return_id,$returnIds,true);}));
$shipments=array_values(array_filter($shipments,function($r)use($returnIds){return in_array((int)$r->purchase_return_id,$returnIds,true);}));
$refundIds=array_map(function($r){return(int)$r->id;},$refunds);$refundReceipts=array_values(array_filter($refundReceipts,function($r)use($refundIds){return in_array((int)$r->supplier_refund_claim_id,$refundIds,true);}));
}
private function uploadedTransaction($input,$entityType,callable$callback)
{
$this->validateFiles($input);$this->db->trans_begin();try{$id=(int)$callback();$this->storeFiles($input,$entityType,$id);if($this->db->trans_status()===false)throw new RuntimeException('Transaksi dan lampiran gagal disimpan lengkap.');$this->db->trans_commit();return$id;}catch(Throwable$e){$this->db->trans_rollback();foreach($this->storedPaths as$path)if(is_file($path))@unlink($path);$this->storedPaths=array();throw$e;}
}
private function validateFiles($input)
{
$files=$_FILES[$input]??array();$names=array_values(array_filter((array)($files['name']??array()),fn($name)=>trim((string)$name)!==''));if(!$names)throw new BusinessException('Minimal satu lampiran gambar atau PDF wajib diunggah.');if(count($names)>10)throw new BusinessException('Maksimal 10 lampiran per dokumen.');$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$i=>$name){if(trim((string)$name)==='')continue;if((int)($files['error'][$i]??UPLOAD_ERR_NO_FILE)!==UPLOAD_ERR_OK)throw new BusinessException('File '.$name.' gagal diterima server.');if((int)($files['size'][$i]??0)>5*1024*1024)throw new BusinessException('Ukuran '.$name.' melebihi 5 MB.');$extension=strtolower(pathinfo($name,PATHINFO_EXTENSION));if(!in_array($extension,$extensions,true))throw new BusinessException('Format .'.$extension.' tidak diizinkan. Gunakan gambar atau PDF.');$tmp=$files['tmp_name'][$i]??'';$mime=$tmp&&is_file($tmp)&&function_exists('finfo_open')?(new finfo(FILEINFO_MIME_TYPE))->file($tmp):($files['type'][$i]??'');if($mime!=='application/pdf'&&strpos((string)$mime,'image/')!==0)throw new BusinessException('Isi file '.$name.' bukan gambar atau PDF yang valid.');}
}
private function storeFiles($input,$entityType,$entityId)
{
$files=$_FILES[$input];$dir=FCPATH.'uploads/accounting/';if(!is_dir($dir)&&!mkdir($dir,0750,true))throw new RuntimeException('Folder lampiran tidak tersedia.');$this->load->library('upload');foreach((array)$files['name']as$i=>$name){if(trim((string)$name)==='')continue;$_FILES['adjustment_attachment']=array('name'=>$name,'type'=>$files['type'][$i],'tmp_name'=>$files['tmp_name'][$i],'error'=>$files['error'][$i],'size'=>$files['size'][$i]);$this->upload->initialize(array('upload_path'=>$dir,'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('adjustment_attachment'))throw new BusinessException('File '.$name.' ditolak: '.strip_tags($this->upload->display_errors()));$file=$this->upload->data();$this->storedPaths[]=$file['full_path'];$this->db->insert('transaction_attachments',array('module'=>'purchase','entity_type'=>$entityType,'entity_id'=>$entityId,'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'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s')));}return true;
}
private function action(callable$callback,$message,$section)
{
if(strtoupper($this->input->method())!=='POST')show_404();try{$result=$callback();if($this->input->is_ajax_request())return json_response(true,$message,array('id'=>$result));$this->session->set_flashdata('success',$message);}catch(Throwable$e){if($this->input->is_ajax_request())return json_response(false,$e->getMessage(),array(),method_exists($e,'getStatusCode')?$e->getStatusCode():422);$this->session->set_flashdata('error',$e->getMessage());}redirect('purchases/returns?section='.$section);
}
private function requireAnyView(){if($this->session->userdata('role')==='Admin')return true;if(check_permission('purchase_returns','can_view')||check_permission('purchase_refunds','can_view')||check_permission('supplier_debit_notes','can_view'))return true;show_error('Anda tidak memiliki izin melihat Retur & Refund.',403,'Akses ditolak');}
private function requireDocumentPermission($type,$action){$feature=in_array($type,array('supplier_refund','supplier_refund_receipt'),true)?'purchase_refunds':($type==='supplier_debit_note'?'supplier_debit_notes':'purchase_returns');return$this->requirePermission($feature,$action);}
private function uid(){return(int)$this->session->userdata('user_id');}
private function roleId(){if($id=(int)$this->session->userdata('role_id'))return$id;$user=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();return$user?(int)$user->role_id:0;}
public function amount($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;}
private function pdfPairs($type,$h)
{
$supplier=trim(($h['supplier_code']??'').' - '.($h['supplier_name']??''));
if(in_array($type,array('purchase_return','return_resolution'),true))return array('Supplier'=>$supplier,'Purchase Order'=>$h['po_no']??'-','Tanggal Pengajuan'=>$h['return_date'],'Kategori Masalah'=>$h['problem_category'],'Penyelesaian Diminta'=>$h['requested_resolution'],'Alasan'=>$h['reason'],'Status'=>$h['status'],'Dibuat oleh'=>$h['created_name']??'-','Diajukan oleh'=>$h['submitted_name']??'-','Disetujui oleh'=>$h['approved_name']??'-','Diselesaikan oleh'=>$h['resolved_name']??'-','Referensi Jurnal'=>$h['journal_id']??'-','Jurnal Reversal'=>$h['reversal_journal_id']??'-');
if($type==='purchase_return_shipment')return array('Supplier'=>$supplier,'Retur Sumber'=>$h['return_no']??'-','Purchase Order'=>$h['po_no']??'-','Tanggal Pengiriman'=>$h['shipment_date']??'-','Kurir'=>$h['courier']??'-','Nomor Resi'=>$h['tracking_no']??'-','Kategori Masalah'=>$h['problem_category']??'-','Alasan Retur'=>$h['reason']??'-','Status'=>$h['status']??'-','Diproses oleh'=>$h['posted_name']??'-');
if($type==='supplier_refund')return array('Supplier'=>$supplier,'PO'=>$h['po_no']??'-','Invoice'=>$h['internal_no']??'-','Tanggal Klaim'=>$h['claim_date'],'Estimasi Diterima'=>$h['expected_date']??'-','Nilai Diajukan'=>'Rp '.number_format($h['claim_amount'],2,',','.'),'Sudah Diterima'=>'Rp '.number_format($h['received_amount'],2,',','.'),'Sisa'=>'Rp '.number_format($h['balance'],2,',','.'),'Alasan'=>$h['reason'],'Status'=>$h['status'],'Dibuat oleh'=>$h['created_name']??'-','Diajukan oleh'=>$h['submitted_name']??'-','Disetujui oleh'=>$h['approved_name']??'-','Ditolak oleh'=>$h['rejected_name']??'-','Jurnal Hak Refund'=>$h['entitlement_journal_id']??'-');
if($type==='supplier_refund_receipt')return array('Supplier'=>$supplier,'Klaim Refund'=>$h['refund_no']??'-','Tanggal Dana Masuk'=>$h['receipt_date']??'-','Akun Penerima'=>trim(($h['kode_akun']??'').' - '.($h['nama_akun']??'')),'Nominal'=>'Rp '.number_format($h['amount'],2,',','.'),'Referensi Bank'=>$h['reference_no']??'-','Status'=>$h['status']??'-','Diproses oleh'=>$h['received_name']??'-','Jurnal'=>$h['journal_id']??'-');
if($type==='supplier_debit_note')return array('Supplier'=>$supplier,'PO'=>$h['po_no']??'-','Invoice'=>$h['internal_no']??'-','Tanggal'=>$h['note_date'],'Jenis Koreksi'=>$h['correction_type'],'Nilai Sebelum Pajak'=>'Rp '.number_format($h['base_amount'],2,',','.'),'Pajak'=>'Rp '.number_format($h['tax_amount'],2,',','.'),'Total'=>'Rp '.number_format($h['amount'],2,',','.'),'Alasan'=>$h['reason'],'Status'=>$h['status'],'Dibuat oleh'=>$h['created_name']??'-','Diposting oleh'=>$h['posted_name']??'-','Jurnal'=>$h['journal_id']??'-','Jurnal Reversal'=>$h['reversal_journal_id']??'-');
return array('Supplier'=>$supplier,'Retur Sumber'=>$h['return_no']??'-','Tanggal Diterima'=>$h['receipt_date']??'-','Gudang'=>$h['warehouse_name']??'-','Surat Jalan'=>$h['supplier_delivery_no']??'-','Status'=>$h['status']??'-');
}
private function pdfText($value){return iconv('UTF-8','windows-1252//TRANSLIT',(string)$value);}
}
+95
View File
@@ -0,0 +1,95 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Purchases extends MY_Controller
{
public function __construct(){parent::__construct();$this->load->library('PurchaseService');}
private function uid(){return(int)$this->session->userdata('user_id');}private function role(){if($id=(int)$this->session->userdata('role_id'))return$id;$u=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();return$u?(int)$u->role_id:0;}
private function departmentName($id){$d=$this->db->get_where('k_departments',array('id'=>(int)$id,'is_active'=>1))->row();return$d?$d->department_name:'';}
public function index(){$data=array('active_menu'=>'purchases','requests'=>$this->db->order_by('id','DESC')->get('purchase_requests')->result(),'orders'=>$this->db->select('purchase_orders.*,suppliers.name')->from('purchase_orders')->join('suppliers','suppliers.id=purchase_orders.supplier_id')->order_by('purchase_orders.id','DESC')->get()->result(),'receipts'=>$this->db->select('goods_receipts.*,purchase_orders.po_no,suppliers.name')->from('goods_receipts')->join('purchase_orders','purchase_orders.id=goods_receipts.purchase_order_id')->join('suppliers','suppliers.id=goods_receipts.supplier_id')->order_by('goods_receipts.id','DESC')->get()->result(),'bills'=>$this->db->select("supplier_invoices.*,suppliers.name,(SELECT po.payment_timing FROM supplier_invoice_lines sil JOIN purchase_order_lines pol ON pol.id=sil.po_line_id JOIN purchase_orders po ON po.id=pol.purchase_order_id WHERE sil.supplier_invoice_id=supplier_invoices.id LIMIT 1) po_payment_timing,(SELECT po.paid_amount FROM supplier_invoice_lines sil JOIN purchase_order_lines pol ON pol.id=sil.po_line_id JOIN purchase_orders po ON po.id=pol.purchase_order_id WHERE sil.supplier_invoice_id=supplier_invoices.id LIMIT 1) po_paid_amount,(SELECT po.total FROM supplier_invoice_lines sil JOIN purchase_order_lines pol ON pol.id=sil.po_line_id JOIN purchase_orders po ON po.id=pol.purchase_order_id WHERE sil.supplier_invoice_id=supplier_invoices.id LIMIT 1) po_total",false)->from('supplier_invoices')->join('suppliers','suppliers.id=supplier_invoices.supplier_id')->order_by('supplier_invoices.id','DESC')->get()->result(),'suppliers'=>$this->db->where('is_active',1)->order_by('name')->get('suppliers')->result(),'catalogs'=>$this->db->order_by('kode_barang')->get('kode_barang')->result(),'warehouses'=>$this->db->order_by('nama')->get('warehouses')->result(),'asset_locations'=>$this->db->order_by('nama')->get('lokasi_asset')->result(),'asset_accounts'=>$this->db->where('tipe','asset')->where('allow_posting',1)->order_by('kode_akun')->get('accounts')->result());$step=$this->input->get('step')?:'request';$this->load->view('partials/header',$data);$this->load->view($step==='request'?'purchases/index':'purchases/workflow',$data);$this->load->view('partials/footer');}
private function requestDocument($id){$header=$this->db->select('pr.*,d.code department_code,d.department_name,creator.nama creator_name,creator.username creator_username,approver.nama approver_name,approver.username approver_username')->from('purchase_requests pr')->join('k_departments d','d.id=pr.department_id','left')->join('users creator','creator.id=pr.requested_by','left')->join('users approver','approver.id=pr.approved_by','left')->where('pr.id',(int)$id)->get()->row();if(!$header||($header->status==='draft'&&(int)$header->requested_by!==$this->uid()))return null;$header->applicant_display=((int)$header->applicant_is_creator===1)?($header->creator_name?:$header->creator_username?:'Pengguna #'.$header->requested_by):$header->applicant_name;$lines=$this->db->select('l.*,k.kode_barang,k.nama master_name')->from('purchase_request_lines l')->join('kode_barang k','k.id=l.kode_barang_id','left')->where('l.purchase_request_id',$header->id)->order_by('l.id')->get()->result();return array('request'=>$header,'lines'=>$lines);}
public function request_detail($id){$data=$this->requestDocument($id);if(!$data)return json_response(false,'Purchase Request tidak ditemukan.',array(),404);return json_response(true,'Detail Purchase Request.',$data);}
public function request_pdf($id){
$data=$this->requestDocument($id);if(!$data)show_404();require_once APPPATH.'third_party/fpdf/fpdf.php';$r=$data['request'];
$creator=$r->creator_name?:$r->creator_username?:'Pengguna #'.$r->requested_by;$requester=$r->applicant_display?:$creator;$approver=$r->approver_name?:$r->approver_username?:'-';
$pdf=new FPDF('P','mm','A4');$pdf->SetMargins(15,15,15);$pdf->AddPage();$pdf->SetFont('Arial','B',15);$pdf->Cell(0,8,'PERMINTAAN PEMBELIAN',0,1,'C');$pdf->SetFont('Arial','',9);$pdf->Cell(0,6,'Nomor: '.$r->request_no,0,1,'C');$pdf->Ln(5);
$pdf->SetFont('Arial','',10);foreach(array('Tanggal'=>$r->request_date,'Dibutuhkan'=>$r->needed_date,'Departemen'=>trim(($r->department_code?:'').' '.($r->department_name?:$r->department)),'Status'=>strtoupper($r->status),'Kebutuhan'=>$r->purpose,'Dibuat oleh'=>$creator,'Diajukan oleh'=>$requester,'Disetujui oleh'=>$approver)as$label=>$value){$pdf->SetFont('Arial','B',10);$pdf->Cell(35,6,$label,0,0);$pdf->SetFont('Arial','',10);$pdf->MultiCell(0,6,': '.($value?:'-'));}
$pdf->Ln(3);$pdf->SetFont('Arial','B',9);foreach(array(12=>'No',38=>'Kode - Barang',70=>'Deskripsi / Spesifikasi',22=>'Qty',30=>'Estimasi')as$w=>$label)$pdf->Cell($w,8,$label,1,0,'C');$pdf->Ln();$no=1;$total=0;foreach($data['lines']as$l){$amount=(float)$l->qty*(float)$l->estimated_price;$total+=$amount;$pdf->SetFont('Arial','',8);$pdf->Cell(12,8,$no++,1,0,'C');$pdf->Cell(38,8,substr(($l->kode_barang?:'-').' - '.($l->master_name?:'-'),0,24),1);$pdf->Cell(70,8,substr($l->description,0,42),1);$pdf->Cell(22,8,number_format($l->qty,0,',','.'),1,0,'R');$pdf->Cell(30,8,number_format($amount,0,',','.'),1,0,'R');$pdf->Ln();}$pdf->SetFont('Arial','B',9);$pdf->Cell(142,8,'TOTAL ESTIMASI',1,0,'R');$pdf->Cell(30,8,number_format($total,0,',','.'),1,1,'R');
$pdf->Ln(14);$pdf->SetFont('Arial','',9);$pdf->Cell(86,6,'Pemohon / Yang Mengajukan',0,0,'C');$pdf->Cell(86,6,'Yang Menyetujui',0,1,'C');$pdf->Ln(18);$pdf->SetFont('Arial','B',9);$pdf->Cell(86,6,'('.$requester.')',0,0,'C');$pdf->Cell(86,6,'('.$approver.')',0,1,'C');$pdf->SetFont('Arial','',8);$pdf->Cell(86,5,'Tanggal: '.$r->request_date,0,0,'C');$pdf->Cell(86,5,'Tanggal: '.($r->approved_at?:'-'),0,1,'C');$pdf->Output('I','PR-'.$r->request_no.'.pdf');
}
public function create_request(){$catalogs=(array)$this->input->post('kode_barang_id');$descriptions=(array)$this->input->post('description',true);$quantities=(array)$this->input->post('qty');$barcodeQuantities=(array)$this->input->post('qty_per_barcode');$prices=(array)$this->input->post('estimated_price');$lines=array();foreach($catalogs as$i=>$catalogId)$lines[]=array('kode_barang_id'=>$catalogId,'description'=>$descriptions[$i]??'','qty'=>$quantities[$i]??0,'qty_per_barcode'=>$barcodeQuantities[$i]??null,'estimated_price'=>$prices[$i]??0);return$this->act(fn()=>$this->purchaseservice->createCatalogRequest(array('purpose'=>$this->input->post('purpose',true),'destination_type'=>'warehouse','date'=>$this->input->post('date'),'needed_date'=>$this->input->post('needed_date'),'department_id'=>$this->input->post('department_id'),'department'=>$this->departmentName($this->input->post('department_id')),'lines'=>$lines,'applicant_is_creator'=>$this->input->post('applicant_is_creator'),'applicant_name'=>$this->input->post('applicant_name',true)),$this->uid()),'Purchase request gudang dibuat dengan '.count($lines).' barang.');}
public function submit_request($id){return$this->act(fn()=>$this->purchaseservice->submitRequest($id,$this->uid()),'Purchase request diajukan.');}public function approve_request($id){$this->requirePermission('purchases','can_approve');return$this->act(fn()=>$this->purchaseservice->approveRequest($id,$this->uid(),$this->role()),'Purchase request disetujui.');}public function reject_request($id){$this->requirePermission('purchases','can_approve');return$this->act(fn()=>$this->purchaseservice->rejectRequest($id,$this->uid(),$this->role(),$this->input->post('reason',true)),'Purchase request ditolak.');}
public function delete_request($id){return$this->act(fn()=>$this->purchaseservice->deleteRequest($id,$this->uid()),'Draft Purchase Request berhasil dihapus.');}
public function create_order(){return$this->act(function(){$method=$this->input->post('procurement_method');$this->validatePurchaseDocuments($method);$id=$this->purchaseservice->createProfessionalOrder(array('request_id'=>$this->input->post('request_id'),'supplier_id'=>$this->input->post('supplier_id'),'warehouse_id'=>$this->input->post('warehouse_id'),'date'=>$this->input->post('date'),'expected_date'=>$this->input->post('expected_date'),'procurement_method'=>$method,'payment_timing'=>$this->input->post('payment_timing'),'marketplace'=>$this->input->post('marketplace',true),'external_order_no'=>$this->input->post('external_order_no',true),'courier'=>$this->input->post('courier',true),'tracking_no'=>$this->input->post('tracking_no',true),'shipping_cost'=>$this->input->post('shipping_cost'),'insurance_cost'=>$this->input->post('insurance_cost'),'service_fee'=>$this->input->post('service_fee'),'discount_amount'=>$this->input->post('discount_amount'),'cashback_amount'=>$this->input->post('cashback_amount'),'down_payment_required'=>$this->input->post('down_payment_required'),'down_payment_amount'=>$this->input->post('down_payment_amount'),'installment_count'=>$this->input->post('installment_count'),'first_due_date'=>$this->input->post('first_due_date'),'unit_prices'=>(array)$this->input->post('unit_price'),'tax_rates'=>(array)$this->input->post('tax_rate'),'notes'=>$this->input->post('notes',true)),$this->uid());$this->applyPoBudgetAccounts($id);$this->storePurchaseDocuments($id);return$id;},'Purchase Order multi-item, akun budget pengeluaran, dan dokumen pendukung berhasil dibuat.');}
private function applyPoBudgetAccounts($poId){
if(!$this->db->field_exists('budget_account_id','purchase_order_lines'))return;
$company=$this->companycontext->id();$accounts=(array)$this->input->post('budget_account_id');
foreach($this->db->where('purchase_order_id',(int)$poId)->get('purchase_order_lines')->result()as$line){
$account=(int)($accounts[$line->request_line_id]??0);
if($account&&!$this->db->get_where('accounts',array('id'=>$account,'company_id'=>$company,'tipe'=>'expense','is_active'=>1,'allow_posting'=>1))->row())throw new BusinessException('Akun budget pengeluaran pada barang PO tidak valid.');
$this->db->where('id',$line->id)->update('purchase_order_lines',array('budget_account_id'=>$account?:null));
}
if($this->db->field_exists('company_id','purchase_orders'))$this->db->where('id',(int)$poId)->update('purchase_orders',array('company_id'=>$company));
}
private function validatePurchaseDocuments($method){$this->validateEvidenceFiles('purchase_documents',$method==='online'?'Bukti/detail pesanan online wajib diunggah.':($method==='direct'?'Foto struk pembelian wajib diunggah.':'Dokumen penawaran/detail order supplier wajib diunggah.'));}
private function scopePurchaseOrderItems($orderId)
{
$companyId=(int)$this->session->userdata('company_id')?:1;
$this->db->query('UPDATE items i JOIN purchase_order_lines pol ON pol.item_id=i.id SET i.company_id=? WHERE pol.purchase_order_id=? AND (i.company_id IS NULL OR i.company_id=0)',array($companyId,(int)$orderId));
$foreign=$this->db->select('i.*,pol.id po_line_id,pol.qty po_qty,pol.unit_price po_unit_price,pol.warehouse_id po_warehouse_id,po.order_date po_date')->from('purchase_order_lines pol')->join('purchase_orders po','po.id=pol.purchase_order_id')->join('items i','i.id=pol.item_id')->where('pol.purchase_order_id',(int)$orderId)->where('i.company_id !=',$companyId)->get()->result();
$itemFields=array_flip($this->db->list_fields('items'));
foreach($foreign as$row){$copy=array_intersect_key((array)$row,$itemFields);unset($copy['id']);$copy['company_id']=$companyId;$suffix='-C'.$companyId.'-PO'.$orderId.'-'.$row->po_line_id;$copy['kode_detail']=substr((string)$row->kode_detail,0,max(1,100-strlen($suffix))).$suffix;$copy['stok']=0;$copy['harga_beli']=$row->po_unit_price;$copy['harga_jual']=0;$copy['status']='draft';if(isset($itemFields['planned_qty']))$copy['planned_qty']=$row->po_qty;if(isset($itemFields['planned_warehouse_id']))$copy['planned_warehouse_id']=$row->po_warehouse_id;if(isset($itemFields['tanggal_pembelian']))$copy['tanggal_pembelian']=$row->po_date;$this->db->insert('items',$copy);$newId=$this->db->insert_id();if(!$newId)throw new BusinessException('Barang PO gagal dipisahkan untuk perusahaan aktif.');$this->db->where('id',$row->po_line_id)->update('purchase_order_lines',array('item_id'=>$newId));}
$remaining=(int)$this->db->select('COUNT(*) total',false)->from('purchase_order_lines pol')->join('items i','i.id=pol.item_id')->where('pol.purchase_order_id',(int)$orderId)->where('i.company_id !=',$companyId)->get()->row()->total;if($remaining>0)throw new BusinessException('Purchase Order memuat barang milik perusahaan lain. Proses dibatalkan.');
}
private function storePurchaseDocuments($entityId,$input='purchase_documents',$entityType='purchase_order')
{
if($entityType==='purchase_order'){
$this->scopePurchaseOrderItems($entityId);
}
$files=$_FILES[$input]??array('name'=>array(),'type'=>array(),'tmp_name'=>array(),'error'=>array(),'size'=>array());$dir=FCPATH.'uploads/accounting/';
if(!is_dir($dir)&&!mkdir($dir,0750,true))throw new BusinessException('Folder lampiran tidak tersedia.');
$count=count((array)$files['name']);if($count>10)throw new BusinessException('Maksimal 10 lampiran untuk satu transaksi.');
$this->load->library('upload');$stored=array();
try{
for($i=0;$i<$count;$i++){
if(empty($files['name'][$i]))continue;
$_FILES['attachment']=array('name'=>$files['name'][$i],'type'=>$files['type'][$i],'tmp_name'=>$files['tmp_name'][$i],'error'=>$files['error'][$i],'size'=>$files['size'][$i]);
$this->upload->initialize(array('upload_path'=>$dir,'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 '.$files['name'][$i].' ditolak: '.strip_tags($this->upload->display_errors()));
$f=$this->upload->data();$stored[]=$f['full_path'];
$this->db->insert('transaction_attachments',array('module'=>'purchase','entity_type'=>$entityType,'entity_id'=>$entityId,'original_name'=>$f['orig_name'],'stored_name'=>$f['file_name'],'mime_type'=>$f['file_type'],'file_size'=>$f['file_size']*1024,'sha256'=>hash_file('sha256',$f['full_path']),'uploaded_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s')));
if(!$this->db->affected_rows())throw new BusinessException('Metadata lampiran gagal disimpan.');
}
return$stored;
}catch(Throwable$e){foreach($stored as$path)if(is_file($path))@unlink($path);throw$e;}
}
public function submit_order($id){return$this->act(fn()=>$this->purchaseservice->submitOrder($id,$this->uid()),'Purchase order diajukan.');}public function approve_order($id){$this->requirePermission('purchases','can_approve');return$this->act(fn()=>$this->purchaseservice->approveOrder($id,$this->uid(),$this->role()),'Purchase order disetujui.');}public function reject_order($id){$this->requirePermission('purchases','can_approve');return$this->act(fn()=>$this->purchaseservice->rejectOrder($id,$this->uid(),$this->role(),$this->input->post('reason',true)),'Purchase order ditolak.');}
public function update_order($id){$this->requirePermission('purchases','can_update');$prices=(array)$this->input->post('unit_price');$taxes=(array)$this->input->post('tax_rate');return$this->act(fn()=>$this->purchaseservice->updateDraftOrder($id,array('supplier_id'=>$this->input->post('supplier_id'),'warehouse_id'=>$this->input->post('warehouse_id'),'date'=>$this->input->post('date'),'expected_date'=>$this->input->post('expected_date'),'procurement_method'=>$this->input->post('procurement_method'),'payment_timing'=>$this->input->post('payment_timing'),'down_payment_required'=>$this->input->post('down_payment_required'),'down_payment_amount'=>$this->input->post('down_payment_amount'),'installment_count'=>$this->input->post('installment_count'),'first_due_date'=>$this->input->post('first_due_date'),'marketplace'=>$this->input->post('marketplace',true),'external_order_no'=>$this->input->post('external_order_no',true),'courier'=>$this->input->post('courier',true),'tracking_no'=>$this->input->post('tracking_no',true),'shipping_cost'=>$this->input->post('shipping_cost'),'insurance_cost'=>$this->input->post('insurance_cost'),'service_fee'=>$this->input->post('service_fee'),'discount_amount'=>$this->input->post('discount_amount'),'cashback_amount'=>$this->input->post('cashback_amount'),'notes'=>$this->input->post('notes',true),'unit_prices'=>$prices,'tax_rates'=>$taxes),$this->uid()),'Draft Purchase Order berhasil diperbarui.');}
public function pay_order($id){$this->requirePermission('purchases','can_post');$accounts=(array)$this->input->post('source_account_id');$amounts=(array)$this->input->post('source_amount');$sources=array();foreach($accounts as$i=>$account)if($account)$sources[(int)$account]=($sources[(int)$account]??0)+$this->purchaseAmount($amounts[$i]??0);$bankCharge=$this->purchaseAmount($this->input->post('bank_charge_amount'));$bankChargeAccount=(int)$this->input->post('bank_charge_account_id');return$this->act(function()use($id,$sources,$bankCharge,$bankChargeAccount){$this->validateEvidenceFiles('payment_documents','Bukti pembayaran wajib diunggah oleh Finance.');$paymentId=$this->purchaseservice->prepayOrderWithBankCharge($id,$this->input->post('date'),$sources,$this->input->post('reference_no',true),$this->input->post('notes',true),$this->uid(),$bankCharge,$bankChargeAccount);$this->storePurchaseDocuments($paymentId,'payment_documents','supplier_payment');return$paymentId;},'Pembayaran PO, biaya admin bank, dan bukti pembayaran berhasil diposting.');}
private function purchaseAmount($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;}
private function validateEvidenceFiles($input,$requiredMessage){$files=$_FILES[$input]??array();$names=array_values(array_filter((array)($files['name']??array()),function($name){return trim((string)$name)!=='';}));if(!$names)throw new BusinessException($requiredMessage);if(count($names)>10)throw new BusinessException('Maksimal 10 lampiran 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$i=>$name){if(trim((string)$name)==='')continue;$error=(int)($files['error'][$i]??UPLOAD_ERR_NO_FILE);if($error!==UPLOAD_ERR_OK)throw new BusinessException($error===UPLOAD_ERR_INI_SIZE||$error===UPLOAD_ERR_FORM_SIZE?'Ukuran file '.$name.' melebihi batas server.':'File '.$name.' gagal diterima oleh server (kode '.$error.').');if((int)($files['size'][$i]??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.');$tmp=$files['tmp_name'][$i]??'';$mime=$tmp&&is_file($tmp)&&function_exists('finfo_open')?(new finfo(FILEINFO_MIME_TYPE))->file($tmp):($files['type'][$i]??'');if($mime!=='application/pdf'&&strpos((string)$mime,'image/')!==0)throw new BusinessException('Isi file '.$name.' bukan PDF atau gambar yang valid.');}}
public function refund_order($id){return$this->movedAdjustment('refund','Proses refund lama dinonaktifkan agar jurnal dan penerimaan dana tidak terduplikasi. Gunakan submenu Retur & Refund.','po_id',(int)$id);}
public function receive($id)
{
$qty=(array)$this->input->post('qty');
return$this->act(function()use($id,$qty){
$this->validateEvidenceFiles('receipt_documents','Bukti penerimaan barang wajib diunggah oleh petugas Gudang.');
$this->db->trans_begin();
try{
$rid=$this->purchaseservice->receive($id,$this->input->post('date'),$this->input->post('delivery_no',true),$qty,$this->uid());
$this->purchaseservice->finalizeReceiptTracking($rid);
$this->purchaseservice->syncReceiptLedger($rid);
$this->purchaseservice->clearTransitForReceipt($rid,$this->uid());
$this->storePurchaseDocuments($rid,'receipt_documents','goods_receipt');
if($this->db->trans_status()===false)throw new BusinessException('Penerimaan gagal disimpan secara lengkap.');
$this->db->trans_commit();return$rid;
}catch(Throwable$e){$this->db->trans_rollback();throw$e;}
},'Penerimaan barang, bukti penerimaan, barcode, dan stock ledger berhasil diposting.');
}
public function create_bill(){return$this->act(function(){$this->validateEvidenceFiles('invoice_documents','Invoice/nota supplier wajib diunggah.');$po=$this->input->post('po_id');$id=$this->purchaseservice->createSupplierInvoice($po,$this->input->post('receipt_id'),$this->input->post('supplier_invoice_no',true),$this->input->post('date'),$this->uid());$this->purchaseservice->syncInvoicePaymentPlan($id,$po);$this->storePurchaseDocuments($id,'invoice_documents','supplier_invoice');$this->purchaseservice->verifySupplierInvoice($id,$this->uid());return$id;},'Invoice Supplier tersimpan, matching terverifikasi, dan siap ditindaklanjuti.');}
public function submit_bill($id){return$this->act(fn()=>$this->purchaseservice->submitSupplierInvoice($id,$this->uid()),'Tagihan diajukan.');}public function approve_bill($id){$this->requirePermission('purchases','can_approve');return$this->act(fn()=>$this->purchaseservice->approveSupplierInvoice($id,$this->uid(),$this->role()),'Tagihan disetujui.');}public function post_bill($id){$this->requirePermission('purchases','can_post');$accounts=(array)$this->input->post('source_account_id');$amounts=(array)$this->input->post('source_amount');$sources=array();foreach($accounts as$i=>$account)if((int)$account)$sources[(int)$account]=($sources[(int)$account]??0)+$this->purchaseAmount($amounts[$i]??0);$bankCharge=$this->purchaseAmount($this->input->post('bank_charge_amount'));$bankChargeAccount=(int)$this->input->post('bank_charge_account_id');return$this->act(function()use($id,$sources,$bankCharge,$bankChargeAccount){$bill=$this->db->select('si.payment_plan,si.total,po.payment_timing,po.paid_amount')->from('supplier_invoices si')->join('supplier_invoice_lines sil','sil.supplier_invoice_id=si.id')->join('purchase_order_lines pol','pol.id=sil.po_line_id')->join('purchase_orders po','po.id=pol.purchase_order_id')->where('si.id',(int)$id)->limit(1)->get()->row();if(!$bill)throw new BusinessException('Invoice supplier tidak ditemukan.');$alreadyPaid=in_array($bill->payment_timing,array('prepaid','on_receipt'),true)&&(float)$bill->paid_amount>=(float)$bill->total-.001;$createsPayment=$bill->payment_plan==='cash'&&!$alreadyPaid;if($createsPayment)$this->validateEvidenceFiles('invoice_payment_documents','Bukti pembayaran invoice supplier wajib diunggah oleh Finance.');$result=$this->purchaseservice->settleSupplierInvoiceMultiWithBankCharge($id,$sources,$this->input->post('payment_date'),$this->input->post('reference_no',true),$this->uid(),$bankCharge,$bankChargeAccount);if($createsPayment&&!empty($result['payment_id']))$this->storePurchaseDocuments($result['payment_id'],'invoice_payment_documents','supplier_payment');return$result;},'Invoice supplier, pembayaran, dan biaya admin bank berhasil diproses.');}
public function verify_bill($id){return$this->act(fn()=>$this->purchaseservice->verifySupplierInvoice($id,$this->uid()),'Matching Invoice Supplier terverifikasi tanpa approval.');}
public function purchase_return(){return$this->movedAdjustment('return','Retur langsung lama dinonaktifkan. Buat pengajuan retur agar approval, reservasi barcode, stok, dan audit trail tetap konsisten.','receipt_line_id',(int)$this->input->post('receipt_line_id'));}
public function debit_note(){return$this->movedAdjustment('debit_note','Debit Note dipindahkan ke submenu Retur & Refund agar saldo invoice dan reversal dapat ditelusuri.','invoice_id',(int)$this->input->post('bill_id'));}
private function movedAdjustment($action,$message,$parameter,$id){if($this->input->is_ajax_request())return json_response(false,$message,array('redirect'=>base_url('purchases/returns?action='.$action.'&'.$parameter.'='.$id)),409);$this->session->set_flashdata('error',$message);redirect('purchases/returns?action='.$action.'&'.$parameter.'='.$id);}
private function act($fn,$msg){if(strtoupper($this->input->method())!=='POST')show_404();$step=$this->input->post('return_step',true)?:$this->input->get('step',true);try{$r=$fn();$this->session->set_flashdata('success',$msg);redirect('purchases'.($step?'?step='.$step:''));}catch(Throwable$e){$this->session->set_flashdata('error',$e->getMessage());redirect('purchases'.($step?'?step='.$step:''));}}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Qr extends CI_Controller {
class Qr extends MY_Controller {
public function __construct()
{
+13
View File
@@ -0,0 +1,13 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Queueworker extends CI_Controller
{
public function run($queue='default',$limit=10){if(!$this->input->is_cli_request())show_404();$this->load->library('JobQueueService');$this->jobqueueservice->recoverStale();$worker=gethostname().':'.getmypid();$done=0;for($i=0;$i<min(100,max(1,(int)$limit));$i++){$job=$this->jobqueueservice->reserve($worker,$queue);if(!$job)break;try{$payload=json_decode($job->payload,true);if(!is_array($payload))throw new RuntimeException('Payload job tidak valid.');$this->handle($job->job_type,$payload,$job);$this->jobqueueservice->complete($job->id);$done++;}catch(Throwable$e){$this->jobqueueservice->fail($job,$e);fwrite(STDERR,'Job '.$job->id.': '.$e->getMessage().PHP_EOL);}}echo $done." job selesai.\n";}
public function replay($deadId){if(!$this->input->is_cli_request())show_404();$dead=$this->db->get_where('app_dead_letters',array('id'=>(int)$deadId))->row();if(!$dead)throw new RuntimeException('Dead-letter tidak ditemukan.');$this->load->library('JobQueueService');$id=$this->jobqueueservice->enqueue($dead->job_type,json_decode($dead->payload,true),'replay:'.$dead->job_id.':'.$dead->id.':'.date('YmdHis'),'default',5,3,$dead->company_id);echo 'Dead-letter direplay sebagai job '.$id.PHP_EOL;}
private function handle($type,array$p,$job){
if($type==='asset_depreciation'){$this->load->library('FixedAssetService');$this->fixedassetservice->depreciate((int)$p['asset_id'],$p['through'],(int)$p['user_id'],true);return;}
if($type==='payroll_calculation'){$this->load->library('PayrollService');$this->payrollservice->calculate($p['month'],(int)$p['user_id']);return;}
if($type==='email'||$type==='reminder'){$this->load->library('email');$this->email->from($p['from']??app_env('MAIL_FROM',''),$p['from_name']??'Accounting');$this->email->to($p['to']);$this->email->subject($p['subject']);$this->email->message($p['message']);if(!$this->email->send(false))throw new RuntimeException('Email gagal dikirim: '.$this->email->print_debugger(array('headers')));return;}
if($type!=='report_export')throw new RuntimeException('Handler job belum tersedia: '.$type);$filters=$p['filters'];$dir=FCPATH.'uploads/accounting/reports/';if(!is_dir($dir)&&!mkdir($dir,0750,true))throw new RuntimeException('Folder export tidak dapat dibuat.');$format=in_array($p['format'],array('pdf','xlsx','excel'),true)?$p['format']:'csv';$file='RPT-'.$job->id.'-'.date('YmdHis').'.'.($format==='pdf'?'html':'csv');$rows=$this->db->query("SELECT j.tanggal,j.no_ref,j.keterangan,a.kode_akun,a.nama_akun,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE j.company_id=? AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ? AND ? ORDER BY j.tanggal,j.id,d.id",array((int)$job->company_id,$filters['from'],$filters['to']))->result_array();$h=fopen($dir.$file,'w');if(!$h)throw new RuntimeException('File export tidak dapat ditulis.');if($format==='pdf')fwrite($h,'<html><body><h2>'.html_escape($p['report_code']).'</h2><pre>'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'</pre></body></html>');elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h);
}
}
@@ -0,0 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Receivableattachments extends MY_Controller
{
public function upload(){$type=$this->input->post('entity_type');$id=(int)$this->input->post('entity_id');if(!in_array($type,array('invoice','payment','credit_note','refund'),true)||$id<1)return json_response(false,'Target lampiran tidak valid.',array(),422);$dir=FCPATH.'uploads/accounting/';if(!is_dir($dir)&&!mkdir($dir,0750,true))return json_response(false,'Folder lampiran tidak tersedia.',array(),500);$config=array('upload_path'=>$dir,'allowed_types'=>'pdf|jpg|jpeg|png|webp','max_size'=>5120,'encrypt_name'=>true);$this->load->library('upload',$config);if(!$this->upload->do_upload('attachment'))return json_response(false,strip_tags($this->upload->display_errors()),array(),422);$f=$this->upload->data();$this->db->insert('transaction_attachments',array('module'=>'receivable','entity_type'=>$type,'entity_id'=>$id,'original_name'=>$f['orig_name'],'stored_name'=>$f['file_name'],'mime_type'=>$f['file_type'],'file_size'=>$f['file_size']*1024,'sha256'=>hash_file('sha256',$f['full_path']),'uploaded_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));return json_response(true,'Lampiran berhasil disimpan.',array('id'=>$this->db->insert_id()));}
public function download($id){$f=$this->db->get_where('transaction_attachments',array('id'=>(int)$id))->row();if(!$f)show_404();$path=FCPATH.'uploads/accounting/'.$f->stored_name;if(!is_file($path))show_404();$this->load->helper('download');force_download($f->original_name,file_get_contents($path));}
}
@@ -0,0 +1,19 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Receivablecheck extends CI_Controller
{
public function __construct(){parent::__construct();if(!is_cli())show_404();}
public function schema(){foreach(array('invoices','invoice_details','payments','customers','stock_logs','item_movements') as $table){echo "--- {$table} ---\n";if(!$this->db->table_exists($table)){echo "MISSING\n";continue;}foreach($this->db->field_data($table) as $f)echo $f->name.' | '.$f->type.' | '.$f->max_length."\n";} $rows=$this->db->select('status,posted,COUNT(*) total')->group_by(array('status','posted'))->get('invoices')->result();foreach($rows as $r)echo "invoice {$r->status} posted={$r->posted} count={$r->total}\n";echo 'payments='.$this->db->count_all('payments')."\n";}
public function accounts(){foreach($this->db->like('nama_akun','pajak')->or_like('nama_akun','piutang')->or_like('nama_akun','utang muka')->or_like('nama_akun','uang muka')->or_like('nama_akun','bad debt')->or_like('nama_akun','kerugian piutang')->order_by('kode_akun')->get('accounts')->result() as $a)echo $a->id.' | '.$a->kode_akun.' | '.$a->nama_akun."\n";}
public function integrity(){
$checks=array(
'duplicate_invoice_numbers'=>"SELECT COUNT(*) total FROM (SELECT no_invoice FROM invoices GROUP BY no_invoice HAVING COUNT(*)>1)x",
'duplicate_payment_numbers'=>"SELECT COUNT(*) total FROM (SELECT payment_no FROM payments GROUP BY payment_no HAVING COUNT(*)>1)x",
'overallocated_payments'=>"SELECT COUNT(*) total FROM (SELECT p.id FROM payments p JOIN payment_allocations a ON a.payment_id=p.id GROUP BY p.id,p.jumlah HAVING SUM(a.amount)>p.jumlah+0.01)x",
'overpaid_invoices'=>"SELECT COUNT(*) total FROM invoices WHERE total_bayar>total+0.01 OR sisa_piutang<0",
'duplicate_invoice_stock_logs'=>"SELECT COUNT(*) total FROM (SELECT ref_id FROM stock_logs WHERE ref_type='invoice_details' AND tipe='keluar' GROUP BY ref_id HAVING COUNT(*)>1)x",
'unbalanced_stage5_journals'=>"SELECT COUNT(*) total FROM (SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('invoice','customer_payment','credit_note','customer_refund','receivable_writeoff','invoice_reversal') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>0.01)x"
);$failed=0;foreach($checks as$name=>$sql){$n=(int)$this->db->query($sql)->row()->total;echo ($n?'[FAIL] ':'[OK] ').$name.'='.$n."\n";if($n)$failed++;}foreach(array('accounts_receivable','sales_tax_payable','customer_advances','bad_debt_expense')as$key){$n=$this->db->where(array('mapping_key'=>$key,'is_active'=>1))->count_all_results('system_account_mappings');echo ($n?'[OK] ':'[FAIL] ').'mapping_'.$key."\n";if(!$n)$failed++;}if($failed)exit(1);
}
public function summary(){foreach(array('sales_documents','payment_allocations','customer_advances','credit_notes','customer_refunds','receivable_writeoffs','receivable_reminders','transaction_attachments')as$t)echo $t.'='.$this->db->count_all($t)."\n";foreach($this->db->select('workflow_status,status,COUNT(*) total')->group_by(array('workflow_status','status'))->get('invoices')->result()as$r)echo 'invoice_'.$r->workflow_status.'_'.$r->status.'='.$r->total."\n";foreach($this->db->select('code')->where_in('code',array('INVOICE_DEFAULT','CREDIT_NOTE_DEFAULT','WRITEOFF_DEFAULT'))->get('approval_workflows')->result()as$r)echo 'workflow='.$r->code."\n";}
}
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Receivablereminders extends MY_Controller
{
public function queue($invoiceId){if(strtoupper($this->input->method())!=='POST')show_404();$i=$this->db->select('invoices.*,customers.email,customers.telp')->from('invoices')->join('customers','customers.id=invoices.customer_id')->where('invoices.id',(int)$invoiceId)->get()->row();if(!$i||$i->sisa_piutang<=0)return json_response(false,'Invoice tidak memiliki saldo.',array(),422);$channel=$this->input->post('channel');if(!in_array($channel,array('email','whatsapp','phone','manual'),true))return json_response(false,'Channel tidak valid.',array(),422);$recipient=$channel==='email'?$i->email:$i->telp;$message='Pengingat invoice '.$i->no_invoice.' saldo Rp '.number_format($i->sisa_piutang,0,',','.').' jatuh tempo '.$i->jatuh_tempo;$this->db->insert('receivable_reminders',array('invoice_id'=>$i->id,'channel'=>$channel,'recipient'=>$recipient,'message'=>$message,'status'=>'queued','scheduled_at'=>$this->input->post('scheduled_at')?:date('Y-m-d H:i:s'),'created_by'=>$this->session->userdata('user_id'),'created_at'=>date('Y-m-d H:i:s')));return json_response(true,'Reminder masuk antrean. Pengiriman provider eksternal belum diaktifkan.',array('id'=>$this->db->insert_id()));}
}
+73
View File
@@ -0,0 +1,73 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Receivables extends MY_Controller
{
public function __construct(){parent::__construct();$this->load->library(array('ReceivableService','ReceivableAdjustmentsService','SalesService'));}
private function uid(){return(int)$this->session->userdata('user_id');}
private function roleId(){$id=(int)$this->session->userdata('role_id');if(!$id){$u=$this->db->select('role_id')->get_where('users',array('id'=>$this->uid()))->row();$id=$u?(int)$u->role_id:0;}return$id;}
private function company(){return(int)$this->companycontext->id();}
public function index()
{
$focusId=(int)$this->input->get('invoice_id');$focus=null;
if($focusId)$focus=$this->db->select('i.id,i.customer_id,i.no_invoice,i.sisa_piutang,c.nama customer_name')->from('invoices i')->join('customers c','c.id=i.customer_id')->where(array('i.id'=>$focusId,'i.company_id'=>$this->company(),'i.workflow_status'=>'posted'))->where('i.sisa_piutang >',0)->get()->row();
$data=array('active_menu'=>'receivables','aging'=>$this->receivableservice->aging($this->input->get('as_of')?:date('Y-m-d')),'focus_invoice'=>$focus,'can_post'=>is_master_admin_user()||check_permission('invoices','can_post'),'summary'=>$this->salesservice->dashboard());
$this->load->view('partials/header',$data);$this->load->view('receivables/index',$data);$this->load->view('partials/footer',$data);
}
public function lookup_customers()
{
$q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20;
$this->db->select('id,nama text,nama')->from('customers')->where(array('company_id'=>$this->company()));if($q!=='')$this->db->like('nama',$q);$rows=$this->db->order_by('nama')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);
return json_response(true,'Customer ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more)));
}
public function lookup_cash_accounts()
{
$q=trim((string)$this->input->get('q'));$page=max(1,(int)$this->input->get('page'));$limit=20;
$this->db->select("a.id,CONCAT(a.kode_akun,' - ',a.nama_akun,' (',IF(a.tipe='asset','Aktiva','Passiva'),')') text",false)->from('accounts a')->where(array('a.company_id'=>$this->company(),'a.is_active'=>1,'a.allow_posting'=>1))->where_in('a.tipe',array('asset','liability'));if(!is_master_admin_user())$this->db->where('a.is_hidden',0);if($q!=='')$this->db->group_start()->like('a.kode_akun',$q)->or_like('a.nama_akun',$q)->group_end();$rows=$this->db->order_by('a.tipe')->order_by('a.kode_akun')->limit($limit+1,($page-1)*$limit)->get()->result();$more=count($rows)>$limit;if($more)array_pop($rows);
return json_response(true,'Akun pelunasan Aktiva/Passiva ditemukan.',array('results'=>$rows,'pagination'=>array('more'=>$more)));
}
public function open_invoices($customerId)
{
$rows=$this->db->select('id,no_invoice,tanggal,jatuh_tempo,total,total_bayar,sisa_piutang,invoice_type')->where(array('company_id'=>$this->company(),'customer_id'=>(int)$customerId,'workflow_status'=>'posted'))->where('sisa_piutang >',0)->order_by('jatuh_tempo')->order_by('id')->get('invoices')->result();
return json_response(true,'Invoice terbuka ditemukan.',array('invoices'=>$rows));
}
public function payment_history($customerId)
{
$rows=$this->db->select("p.id,p.payment_no,p.tanggal,p.jumlah,p.reference_no,p.created_at,u.nama creator_name,GROUP_CONCAT(CONCAT(i.no_invoice,' · Rp ',FORMAT(pa.amount,2,'id_ID')) ORDER BY i.id SEPARATOR '||') allocations",false)->from('payments p')->join('payment_allocations pa','pa.payment_id=p.id','left')->join('invoices i','i.id=pa.invoice_id','left')->join('users u','u.id=p.created_by','left')->where(array('p.company_id'=>$this->company(),'p.customer_id'=>(int)$customerId,'p.status'=>'posted'))->group_by('p.id')->order_by('p.id','DESC')->limit(50)->get()->result();
return json_response(true,'Riwayat pembayaran ditemukan.',array('payments'=>$rows));
}
public function submit_invoice($id){return$this->action(fn()=>$this->salesservice->submitInvoice($id,$this->uid()),'Invoice diajukan untuk approval.');}
public function approve_invoice($id){return$this->action(fn()=>$this->salesservice->approveAndPostInvoice($id,$this->uid(),$this->roleId()),'Invoice disetujui dan otomatis diposting.');}
public function post_invoice($id){return json_response(false,'Posting terpisah dinonaktifkan. Approval terakhir otomatis memposting invoice.',array('redirect'=>base_url('invoices/detail/'.(int)$id)),410);}
public function payment()
{
$saved=array();
try{
$saved=$this->paymentUploads();$sources=json_decode((string)$this->input->post('sources'),true);$allocations=json_decode((string)$this->input->post('allocations_json'),true);
if(!is_array($sources))$sources=array(array('account_id'=>$this->input->post('cash_account_id'),'amount'=>$this->input->post('amount')));
if(!is_array($allocations)){$allocations=array();foreach((array)$this->input->post('allocations')as$id=>$amount)$allocations[(int)$id]=$amount;}
$result=$this->salesservice->recordPayment($this->input->post('customer_id'),$this->input->post('date'),$sources,$allocations,(bool)$this->input->post('allow_advance'),$this->uid(),$this->input->post('reference_no',true),$this->input->post('notes',true),$saved);
return json_response(true,'Pembayaran berhasil diposting.',array('result'=>$result));
}catch(Throwable$e){foreach($saved as$f)if(is_file(FCPATH.$f['stored_name']))@unlink(FCPATH.$f['stored_name']);return business_exception_response($e);}
}
public function reverse_invoice($id){$this->requireAdmin();return$this->action(fn()=>$this->receivableservice->reverseInvoice($id,$this->input->post('date'),$this->input->post('reason',true),$this->uid()),'Invoice berhasil direversal.');}
public function writeoff($id){$this->requireAdmin();return$this->action(fn()=>$this->receivableservice->postWriteoff($id,$this->input->post('date'),$this->input->post('amount'),$this->input->post('reason',true),$this->uid()),'Write-off berhasil diposting.');}
public function credit_note($id){$this->requireAdmin();return$this->action(fn()=>$this->receivableadjustmentsservice->creditNote($id,$this->input->post('date'),$this->input->post('amount'),$this->input->post('reason',true),$this->uid()),'Credit note berhasil diposting.');}
public function refund($advanceId){$this->requireAdmin();return$this->action(fn()=>$this->receivableadjustmentsservice->refund($advanceId,$this->input->post('date'),$this->input->post('amount'),$this->input->post('bank_account_id'),$this->input->post('reason',true),$this->uid()),'Refund berhasil diposting.');}
public function statement($customerId){$from=$this->input->get('from')?:date('Y-01-01');$to=$this->input->get('to')?:date('Y-m-d');$data=$this->receivableservice->statement($customerId,$from,$to);$data+=array('active_menu'=>'receivables','from'=>$from,'to'=>$to);$this->load->view('partials/header',$data);$this->load->view('receivables/statement',$data);$this->load->view('partials/footer',$data);}
public function receipt($paymentId){redirect('sales/receipt_pdf/'.(int)$paymentId);}
private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$fn()));}catch(Throwable$e){return business_exception_response($e);}}
private function paymentUploads()
{
$f=$_FILES['attachments']??null;if(!$f||empty($f['name']))throw new BusinessException('Bukti pembayaran gambar atau PDF wajib diunggah.');$names=is_array($f['name'])?$f['name']:array($f['name']);if(count($names)>10)throw new BusinessException('Maksimal 10 file.');$dir=FCPATH.'uploads/accounting/sales/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new BusinessException('Folder lampiran tidak tersedia.');$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','application/pdf'=>'pdf');$fi=new finfo(FILEINFO_MIME_TYPE);$out=array();foreach($names as$i=>$name){$tmp=is_array($f['tmp_name'])?$f['tmp_name'][$i]:$f['tmp_name'];$size=is_array($f['size'])?$f['size'][$i]:$f['size'];$err=is_array($f['error'])?$f['error'][$i]:$f['error'];$mime=$err===UPLOAD_ERR_OK?$fi->file($tmp):'';if(!isset($allowed[$mime])||$size>5*1024*1024)throw new BusinessException('Bukti hanya menerima JPG, PNG, WEBP, GIF, atau PDF maksimal 5 MB.');$stored=bin2hex(random_bytes(16)).'.'.$allowed[$mime];if(!move_uploaded_file($tmp,$dir.$stored))throw new BusinessException('Bukti tidak dapat disimpan.');$out[]=array('original_name'=>basename($name),'stored_name'=>'uploads/accounting/sales/'.$stored,'mime_type'=>$mime,'file_size'=>$size,'sha256'=>hash_file('sha256',$dir.$stored));}return$out;
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Reportworker extends CI_Controller
{
public function run(){if(!$this->input->is_cli_request())show_404();$this->load->database();$jobs=$this->db->where('status','queued')->order_by('id')->limit(10)->get('report_export_jobs')->result();foreach($jobs as$j){$this->db->where('id',$j->id)->update('report_export_jobs',array('status'=>'processing','started_at'=>date('Y-m-d H:i:s')));try{$f=json_decode($j->filter_json,true);$where="j.company_id=".(int)$j->company_id." AND j.status IN('posted','reversed') AND j.tanggal BETWEEN ".$this->db->escape($f['from'])." AND ".$this->db->escape($f['to']);$rows=$this->db->query("SELECT j.tanggal,j.no_ref,j.keterangan,a.kode_akun,a.nama_akun,d.debit,d.kredit FROM journal_details d JOIN journals j ON j.id=d.journal_id JOIN accounts a ON a.id=d.account_id WHERE $where ORDER BY j.tanggal,j.id,d.id")->result_array();$dir=FCPATH.'uploads/accounting/reports/';if(!is_dir($dir))mkdir($dir,0750,true);$file=$j->job_no.'.'.($j->format==='pdf'?'html':'csv');$h=fopen($dir.$file,'w');if($j->format==='pdf'){fwrite($h,'<html><body><h2>'.html_escape($j->report_code).'</h2><pre>'.html_escape(json_encode($rows,JSON_PRETTY_PRINT)).'</pre></body></html>');}elseif($rows){fputcsv($h,array_keys($rows[0]));foreach($rows as$r)fputcsv($h,$r);}fclose($h);$this->db->where('id',$j->id)->update('report_export_jobs',array('status'=>'completed','file_name'=>$file,'completed_at'=>date('Y-m-d H:i:s')));}catch(Throwable$e){$this->db->where('id',$j->id)->update('report_export_jobs',array('status'=>'failed','error_message'=>$e->getMessage(),'completed_at'=>date('Y-m-d H:i:s')));}}echo count($jobs)." job diproses.\n";}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Rolecheck extends CI_Controller
{
public function index(){if(!$this->input->is_cli_request())show_404();$view=$this->db->query('SHOW CREATE VIEW `v_users`')->row_array();echo(isset($view['Create View'])?$view['Create View']:'v_users tidak ditemukan').PHP_EOL;foreach($this->db->order_by('id')->get('roles')->result_array()as$r)echo json_encode($r,JSON_UNESCAPED_UNICODE).PHP_EOL;}
}
+47
View File
@@ -0,0 +1,47 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Roles extends MY_Admin_Controller
{
private $features; private $actions;
public function __construct()
{
parent::__construct();
$this->config->load('permissions', true);
$cfg = $this->config->item('permissions');
$this->features = $cfg['permission_features']; $this->actions = $cfg['permission_actions'];
}
public function index()
{
$data = array('active_menu'=>'roles','roles'=>$this->db->order_by('is_super_admin','DESC')->order_by('is_system','DESC')->order_by('nama_role')->get('roles')->result(),'features'=>$this->features,'actions'=>$this->actions);
$this->load->view('partials/header',$data);$this->load->view('roles/index',$data);$this->load->view('partials/footer');
}
public function detail($id)
{
$role=$this->db->get_where('roles',array('id'=>(int)$id))->row();if(!$role)return json_response(false,'Role tidak ditemukan.',array(),404);
$role->permissions=json_decode($role->permissions,true)?:array();return json_response(true,'Role ditemukan.',array('result'=>(array)$role));
}
public function save()
{
$id=(int)$this->input->post('id');$name=trim((string)$this->input->post('name',true));$description=trim((string)$this->input->post('description',true));
if($name===''||$description==='')return json_response(false,'Nama dan penjelasan tanggung jawab role wajib diisi.',array(),422);
$existing=$id?$this->db->get_where('roles',array('id'=>$id))->row():null;
if($id&&!$existing)return json_response(false,'Role tidak ditemukan.',array(),404);
if($existing&&!empty($existing->is_super_admin))return json_response(false,'Permission Master Admin bersifat penuh dan tidak dapat dibatasi.',array(),422);
$duplicate=$this->db->where('nama_role',$name)->where('id !=',$id)->count_all_results('roles');if($duplicate)return json_response(false,'Nama role sudah digunakan.',array(),422);
$posted=(array)$this->input->post('permissions');$permissions=array();
foreach($this->features as$key=>$label){$allowedActions=array_keys($this->actions);if($key==='payroll')$allowedActions=array_merge($allowedActions,array('can_manage_all_salary','can_manage_allowances'));$selected=array_values(array_intersect((array)(isset($posted[$key])?$posted[$key]:array()),$allowedActions));if($key==='payroll'&&in_array('can_manage_allowances',$selected,true)&&!in_array('can_update',$selected,true))$selected[]='can_update';if($selected){if(!in_array('can_view',$selected,true))array_unshift($selected,'can_view');$permissions[$key]=$selected;}}
$hasDevicePermission=false;foreach(array_keys($permissions)as$feature){if(strpos($feature,'technician_')===0){$hasDevicePermission=true;break;}}
if($hasDevicePermission){if(empty($permissions['technician_equipment']))$permissions['technician_equipment']=array('can_view');elseif(!in_array('can_view',$permissions['technician_equipment'],true))array_unshift($permissions['technician_equipment'],'can_view');}
if(!$permissions)return json_response(false,'Role wajib mempunyai minimal satu permission.',array(),422);
$data=array('nama_role'=>$name,'description'=>$description,'permissions'=>json_encode($permissions),'is_active'=>$this->input->post('is_active')?1:0,'updated_at'=>date('Y-m-d H:i:s'));
if($id)$this->db->where('id',$id)->update('roles',$data);else{$data['is_system']=0;$data['is_super_admin']=0;$data['created_at']=date('Y-m-d H:i:s');$this->db->insert('roles',$data);$id=$this->db->insert_id();}
log_activity('roles',$existing?'update':'create',($existing?'Mengubah':'Membuat').' role '.$name,'success');return json_response(true,'Role dan permission berhasil disimpan.',array('id'=>$id));
}
public function delete($id)
{
$role=$this->db->get_where('roles',array('id'=>(int)$id))->row();if(!$role)return json_response(false,'Role tidak ditemukan.',array(),404);
if($role->is_system||$role->is_super_admin)return json_response(false,'Role bawaan aplikasi tidak dapat dihapus.',array(),422);
if($this->db->where('role_id',$role->id)->count_all_results('users'))return json_response(false,'Role masih digunakan oleh pengguna.',array(),422);
$this->db->where('id',$role->id)->delete('roles');return json_response(true,'Role custom berhasil dihapus.');
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Sales extends MY_Controller
{
public function __construct(){parent::__construct();$this->load->library('SalesService');}
private function uid(){return(int)$this->session->userdata('user_id');}
private function roleId(){return(int)$this->session->userdata('role_id');}
private function allowed($action){return is_master_admin_user()||check_permission('invoices',$action);}
public function index()
{
$sourceOrder=(int)$this->input->get('source_order');if($sourceOrder)redirect('invoices/create?source_order='.$sourceOrder);
$this->renderWorkspace('summary');
}
public function deliveries(){return $this->renderWorkspace('delivery');}
public function returns(){return $this->renderWorkspace('return');}
private function renderWorkspace($section)
{
$active=$section==='delivery'?'sales_delivery':($section==='return'?'sales_return':'sales');
$data=array('active_menu'=>$active,'section'=>$section,'summary'=>$this->salesservice->dashboard(),'deliveries'=>$this->salesservice->deliveries(),'returns'=>$this->salesservice->returns(),'accounts'=>array(),'current_user'=>$this->uid(),'source_order'=>0,'focus'=>(int)$this->input->get('focus'),'permissions'=>array('create'=>$this->allowed('can_create'),'update'=>$this->allowed('can_update'),'approve'=>$this->allowed('can_approve'),'post'=>$this->allowed('can_post'),'delete'=>$this->allowed('can_delete'),'export'=>$this->allowed('can_export')));
$this->load->view('partials/header',$data);$this->load->view('sales/index',$data);$this->load->view('partials/footer',$data);
}
public function lookup_customers(){return$this->lookup('customers','nama',array('id','nama','alamat'));}
public function lookup_items()
{
$q=trim((string)$this->input->get('q'));$company=(int)$this->companycontext->id();$this->db->select('i.id,i.kode_detail AS kode_barang,i.nama_barang,i.harga_jual,i.harga_beli,i.kode_id,k.tracking_type,k.unit')->from('items i')->join('kode_barang k','k.id=i.kode_id','left')->where(array('i.company_id'=>$company,'i.status'=>'active'))->group_start()->like('i.kode_detail',$q)->or_like('i.nama_barang',$q)->group_end()->order_by('i.nama_barang')->limit(30);return json_response(true,'Barang ditemukan.',array('rows'=>$this->db->get()->result()));
}
public function lookup_barcodes()
{
$item=(int)$this->input->get('item_id');$warehouse=(int)$this->input->get('warehouse_id');$company=(int)$this->companycontext->id();$rows=$this->db->select('b.id,b.barcode,b.serial_number,b.qty_sisa,b.reserved_qty,b.return_reserved_qty')->from('item_barcodes b')->join('items i','i.id=b.item_id')->where(array('i.company_id'=>$company,'b.item_id'=>$item,'b.warehouse_id'=>$warehouse,'b.status'=>'available'))->where('(b.qty_sisa-b.reserved_qty-COALESCE(b.return_reserved_qty,0)) >',0,false)->order_by('b.id')->limit(100)->get()->result();return json_response(true,'Barcode tersedia.',array('rows'=>$rows));
}
private function lookup($table,$label,array$fields){$q=trim((string)$this->input->get('q'));$this->db->select(implode(',',$fields))->where('company_id',(int)$this->companycontext->id())->like($label,$q)->order_by($label)->limit(30);if($this->db->field_exists('is_active',$table))$this->db->where('is_active',1);return json_response(true,'Data ditemukan.',array('rows'=>$this->db->get($table)->result()));}
public function warehouses(){return json_response(true,'Gudang ditemukan.',array('rows'=>$this->db->select('id,nama')->where('company_id',(int)$this->companycontext->id())->order_by('nama')->get('warehouses')->result()));}
public function create_invoice(){return$this->action(function(){return$this->salesservice->createInvoice($this->input->post(),$this->decodeRows(),$this->uid());},'Invoice berhasil disimpan sebagai Draft/Berjalan.');}
public function append_invoice($id){return$this->action(function()use($id){return$this->salesservice->appendInvoiceLines($id,$this->decodeRows(),$this->uid());},'Item berhasil ditambahkan ke invoice berjalan.');}
public function submit_invoice($id){return$this->action(function()use($id){return$this->salesservice->submitInvoice($id,$this->uid());},'Invoice diajukan untuk approval.');}
public function approve_invoice($id){return$this->action(function()use($id){return$this->salesservice->approveAndPostInvoice($id,$this->uid(),$this->roleId());},'Invoice disetujui dan otomatis diposting.');}
public function reject_invoice($id){return$this->action(function()use($id){return$this->salesservice->rejectInvoice($id,$this->input->post('reason',true),$this->uid(),$this->roleId());},'Invoice ditolak dan dikembalikan ke Draft.');}
public function delete_invoice($id){return$this->action(function()use($id){return$this->salesservice->deleteDraftInvoice($id,$this->uid(),is_master_admin_user());},'Draft invoice dibatalkan dan reservasi barcode dilepas.');}
public function post_delivery($id){return$this->action(function()use($id){return$this->salesservice->postDelivery($id,$this->uid());},'Surat Jalan diposting dan stok telah diperbarui.');}
public function update_delivery($id){$saved=array();try{$saved=$this->uploads('attachments',false);$result=$this->salesservice->updateDelivery($id,$this->input->post(),$saved,$this->uid());return json_response(true,'Data dan bukti pengiriman berhasil disimpan.',array('result'=>$result));}catch(Throwable$e){$this->cleanup($saved);return business_exception_response($e);}}
public function invoice_detail($id){return$this->jsonDetail(function()use($id){$this->guardInvoice($this->salesservice->invoiceForEditor($id));return$this->salesservice->invoiceDetail($id);});}
public function delivery_detail($id){return$this->jsonDetail(function()use($id){return$this->salesservice->deliveryDetail($id);});}
public function return_detail($id){return$this->jsonDetail(function()use($id){return$this->salesservice->returnDetail($id);});}
public function payment()
{
return json_response(false,'Alur pembayaran lama dinonaktifkan. Gunakan menu Piutang & Pembayaran.',array('redirect'=>base_url('receivables')),410);
}
public function create_return(){$saved=array();try{if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);$saved=$this->uploads('attachments',true);$id=$this->salesservice->createReturn($this->input->post(),$this->decodeRows(),$this->uid(),$saved);return json_response(true,'Pengajuan retur berhasil disimpan.',array('result'=>$id));}catch(Throwable$e){$this->cleanup($saved);return business_exception_response($e);}}
public function submit_return($id){return$this->action(function()use($id){return$this->salesservice->submitReturn($id,$this->uid());},'Retur diajukan.');}
public function approve_return($id){return$this->action(function()use($id){return$this->salesservice->approveReturn($id,$this->uid());},'Retur disetujui.');}
public function reject_return($id){return$this->action(function()use($id){return$this->salesservice->rejectReturn($id,$this->input->post('reason',true),$this->uid());},'Retur ditolak.');}
public function receive_return($id){return$this->action(function()use($id){return$this->salesservice->receiveReturn($id,$this->uid());},'Barang retur diterima dan diperiksa.');}
public function post_return($id){return$this->action(function()use($id){return$this->salesservice->postReturn($id,$this->uid());},'Retur, stok, dan credit note berhasil diposting.');}
public function invoice_pdf($id){$this->guardInvoice($this->salesservice->invoiceForEditor($id));$data=$this->salesservice->invoiceDetail($id);$data['previous_invoices']=(int)$this->input->get('include_previous')===1?$this->salesservice->previousOutstandingInvoices($id):array();$this->pdf('sales/invoice_pdf',$data,'Invoice-'.$data['invoice']->no_invoice.'.pdf');}
public function delivery_pdf($id){$data=$this->salesservice->deliveryDetail($id);$this->pdf('sales/delivery_pdf',$data,'Surat-Jalan-'.$data['delivery']->delivery_no.'.pdf');}
public function return_pdf($id){$data=$this->salesservice->returnDetail($id);$this->pdf('sales/return_pdf',$data,'Retur-'.$data['return']->return_no.'.pdf');}
public function receipt_pdf($id)
{
$company=(int)$this->companycontext->id();
$data['payment']=$this->db
->select('p.*,c.nama customer_name,c.alamat customer_address,c.telp customer_phone,c.email customer_email,u.nama creator_name')
->from('payments p')
->join('customers c','c.id=p.customer_id')
->join('users u','u.id=p.created_by','left')
->where(array('p.id'=>(int)$id,'p.company_id'=>$company))
->get()
->row();
if(!$data['payment'])show_404();
$data['allocations']=$this->db
->select('a.*,i.no_invoice,i.tanggal invoice_date,i.jatuh_tempo')
->from('payment_allocations a')
->join('invoices i','i.id=a.invoice_id')
->where(array('a.payment_id'=>(int)$id,'i.company_id'=>$company))
->order_by('i.tanggal','ASC')
->order_by('i.id','ASC')
->get()
->result();
$data['sources']=$this->db
->select('s.*,a.kode_akun,a.nama_akun')
->from('payment_sources s')
->join('accounts a','a.id=s.account_id')
->where(array('s.payment_id'=>(int)$id,'s.company_id'=>$company))
->order_by('s.id','ASC')
->get()
->result();
$this->pdf('sales/receipt_pdf',$data,'Bukti-Penerimaan-'.$data['payment']->payment_no.'.pdf');
}
private function decodeRows(){$rows=json_decode((string)$this->input->post('rows'),true);if(!is_array($rows))throw new BusinessException('Detail barang tidak valid.');return$rows;}
private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$fn()));}catch(Throwable$e){return business_exception_response($e);}}
private function jsonDetail($fn){try{return json_response(true,'Detail ditemukan.',$fn());}catch(Throwable$e){return business_exception_response($e);}}
private function pdf($view,$data,$name)
{
$company=(int)$this->companycontext->id();$data['pdf_company']=$this->db->get_where('companies',array('id'=>$company))->row();$currency=!empty($data['invoice']->transaction_currency)?$data['invoice']->transaction_currency:($data['pdf_company']->base_currency??'IDR');$data['pdf_currency']=$this->db->get_where('currencies',array('code'=>$currency))->row();$data['pdf_bank_accounts']=array();
if($this->db->table_exists('company_bank_accounts')){$this->db->where(array('company_id'=>$company,'is_active'=>1));if($this->db->field_exists('show_on_invoice','company_bank_accounts'))$this->db->where('show_on_invoice',1);$data['pdf_bank_accounts']=$this->db->order_by('is_primary','DESC')->order_by('id')->get('company_bank_accounts')->result();}
$data['pdf_logo_data']='';$path=$data['pdf_company']->logo_path??'';if($path!==''&&is_file(FCPATH.$path)){$mime=function_exists('mime_content_type')?mime_content_type(FCPATH.$path):'image/png';$data['pdf_logo_data']='data:'.$mime.';base64,'.base64_encode(file_get_contents(FCPATH.$path));}require_once FCPATH.'vendor/autoload.php';$html=$this->load->view($view,$data,true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','portrait');$pdf->render();$pdf->stream($name,array('Attachment'=>false));
}
private function uploads($field,$required=false)
{
if(empty($_FILES[$field])||empty($_FILES[$field]['name'])){if($required)throw new BusinessException('Bukti pembayaran gambar atau PDF wajib diunggah.');return array();}$files=$_FILES[$field];$names=is_array($files['name'])?$files['name']:array($files['name']);$saved=array();if(count($names)>10)throw new BusinessException('Maksimal 10 file.');$dir=FCPATH.'uploads/accounting/sales/';if(!is_dir($dir)&&!mkdir($dir,0755,true))throw new BusinessException('Folder lampiran tidak dapat dibuat.');$finfo=new finfo(FILEINFO_MIME_TYPE);$allowed=array('image/jpeg'=>'jpg','image/png'=>'png','image/webp'=>'webp','image/gif'=>'gif','application/pdf'=>'pdf');
foreach($names as$i=>$name){$tmp=is_array($files['tmp_name'])?$files['tmp_name'][$i]:$files['tmp_name'];$size=is_array($files['size'])?$files['size'][$i]:$files['size'];$error=is_array($files['error'])?$files['error'][$i]:$files['error'];if($error!==UPLOAD_ERR_OK)throw new BusinessException('Salah satu bukti gagal diunggah.');$mime=$finfo->file($tmp);if(!isset($allowed[$mime]))throw new BusinessException('Lampiran hanya menerima JPG, PNG, WEBP, GIF, atau PDF.');if($size>5*1024*1024)throw new BusinessException('Ukuran setiap lampiran maksimal 5 MB.');$stored=bin2hex(random_bytes(16)).'.'.$allowed[$mime];if(!move_uploaded_file($tmp,$dir.$stored))throw new BusinessException('Lampiran tidak dapat disimpan.');$saved[]=array('original_name'=>basename($name),'stored_name'=>'uploads/accounting/sales/'.$stored,'mime_type'=>$mime,'file_size'=>$size,'sha256'=>hash_file('sha256',$dir.$stored));}return$saved;
}
private function cleanup(array$files){foreach($files as$f){$path=FCPATH.$f['stored_name'];if(is_file($path))@unlink($path);}}
private function guardInvoice($invoice){if($invoice->workflow_status==='draft'&&!is_master_admin_user()&&(int)$invoice->created_by!==$this->uid())throw new BusinessException('Draft invoice hanya dapat dilihat oleh pembuatnya.');}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Salescheck extends CI_Controller
{
public function index()
{
if(!is_cli())show_404();$this->load->database();$errors=array();$checks=array();
$required=array('invoices'=>array('company_id','invoice_type','recognition_policy','delivery_status','recognized_revenue','idempotency_key','version'),'invoice_details'=>array('company_id','service_date','delivery_line_id','net_amount','line_type','counter_account_id','idempotency_key'),'sales_deliveries'=>array('company_id','delivery_no','status'),'sales_delivery_barcodes'=>array('barcode_id','qty','returned_qty'),'invoice_line_barcodes'=>array('invoice_id','barcode_id'),'payment_sources'=>array('payment_id','account_id','amount'),'sales_returns'=>array('company_id','return_no','status'),'revenue_recognition_ledger'=>array('idempotency_key'));
foreach($required as$table=>$columns){if(!$this->db->table_exists($table)){$errors[]='Tabel '.$table.' belum ada.';continue;}$fields=$this->db->list_fields($table);foreach($columns as$c)if(!in_array($c,$fields,true))$errors[]='Kolom '.$table.'.'.$c.' belum ada.';}$checks['schema']=!$errors;
if($this->db->table_exists('sales_delivery_lines')){$r=$this->db->query("SELECT COUNT(*) total FROM sales_delivery_lines l JOIN sales_deliveries d ON d.id=l.sales_delivery_id WHERE d.status='posted' AND l.stock_posted_at IS NULL")->row();$checks['posted_delivery_has_stock']=(int)$r->total===0;if(!$checks['posted_delivery_has_stock'])$errors[]=$r->total.' baris Surat Jalan posted belum mempunyai posting stok.';}
if($this->db->table_exists('inventory_ledger')){$r=$this->db->query("SELECT COUNT(*) total FROM(SELECT idempotency_key FROM inventory_ledger WHERE document_type IN('sales_delivery','sales_return') GROUP BY idempotency_key HAVING COUNT(*)>1)x")->row();$checks['stock_idempotency']=(int)$r->total===0;if(!$checks['stock_idempotency'])$errors[]='Terdapat idempotency key stok penjualan ganda.';}
if($this->db->table_exists('invoices')){$r=$this->db->query("SELECT COUNT(*) total FROM invoices WHERE company_id IS NULL OR recognized_revenue>subtotal_before_tax+0.01 OR recognized_cogs<0")->row();$checks['invoice_tenant_recognition']=(int)$r->total===0;if(!$checks['invoice_tenant_recognition'])$errors[]=$r->total.' invoice tidak lolos validasi tenant/pengakuan.';}
if($this->db->table_exists('journals')&&$this->db->table_exists('journal_details')){$r=$this->db->query("SELECT COUNT(*) total FROM(SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('sales_delivery','invoice','customer_payment','revenue_recognition','sales_return') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>0.01)x")->row();$checks['sales_journals_balanced']=(int)$r->total===0;if(!$checks['sales_journals_balanced'])$errors[]='Terdapat jurnal penjualan tidak balance.';}
if($this->db->table_exists('invoice_line_barcodes')){$r=$this->db->query("SELECT COUNT(*) total FROM(SELECT invoice_id,barcode_id FROM invoice_line_barcodes GROUP BY invoice_id,barcode_id HAVING COUNT(*)>1)x")->row();$checks['barcode_once_per_invoice']=(int)$r->total===0;if(!$checks['barcode_once_per_invoice'])$errors[]='Terdapat barcode yang dialokasikan lebih dari sekali pada invoice yang sama.';}
if($this->db->table_exists('invoice_details')){$r=$this->db->query("SELECT COUNT(*) total FROM invoice_details WHERE line_type IN('service','loan','savings') AND delivery_line_id IS NOT NULL")->row();$checks['noninventory_without_delivery']=(int)$r->total===0;if(!$checks['noninventory_without_delivery'])$errors[]='Terdapat item nonbarang yang terhubung ke Surat Jalan.';}
if($this->db->table_exists('invoice_details')){$nullable=array();foreach($this->db->query("SHOW COLUMNS FROM invoice_details WHERE Field IN ('items_id','warehouse_id')")->result()as$c)$nullable[$c->Field]=$c->Null;$checks['service_columns_nullable']=($nullable['items_id']??'NO')==='YES'&&($nullable['warehouse_id']??'NO')==='YES';if(!$checks['service_columns_nullable'])$errors[]='Kolom barang/gudang invoice belum nullable sehingga baris jasa tidak dapat disimpan.';}
if($this->db->table_exists('payments')){$this->db->select("p.id,p.payment_no,p.tanggal,p.jumlah,p.reference_no,p.created_at,u.nama creator_name,GROUP_CONCAT(CONCAT(i.no_invoice,' · Rp ',FORMAT(pa.amount,2,'id_ID')) ORDER BY i.id SEPARATOR '||') allocations",false)->from('payments p')->join('payment_allocations pa','pa.payment_id=p.id','left')->join('invoices i','i.id=pa.invoice_id','left')->join('users u','u.id=p.created_by','left')->group_by('p.id')->limit(1)->get()->result();$checks['payment_history_query']=true;}
if($this->db->table_exists('accounts')){$revenue=(int)$this->db->where(array('tipe'=>'revenue','is_active'=>1,'allow_posting'=>1))->count_all_results('accounts');$checks['revenue_account_available']=$revenue>0;if(!$checks['revenue_account_available'])$errors[]='Belum ada akun pendapatan aktif yang dapat diposting.';}
if($this->db->table_exists('cash_accounts')){$cash=(int)$this->db->where('is_active',1)->count_all_results('cash_accounts');$checks['cash_account_available']=$cash>0;if(!$checks['cash_account_available'])$errors[]='Belum ada rekening Kas/Bank aktif untuk penerimaan customer.';}
$checks['pdf_views']=is_file(APPPATH.'views/sales/invoice_pdf.php')&&is_file(APPPATH.'views/sales/delivery_pdf.php')&&is_file(APPPATH.'views/sales/return_pdf.php')&&is_file(APPPATH.'views/sales/receipt_pdf.php');if(!$checks['pdf_views'])$errors[]='Salah satu template PDF belum tersedia.';
echo json_encode(array('status'=>!$errors,'checks'=>$checks,'errors'=>$errors,'cleanup'=>'Tidak membuat data uji; tidak ada fixture yang tertinggal.'),JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES).PHP_EOL;if($errors)exit(1);
}
}
@@ -0,0 +1,14 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Salesdocuments extends MY_Controller
{
public function __construct(){parent::__construct();$this->load->library(array('NumberingService','AuditService','TransactionService'));}
private function company(){return(int)$this->companycontext->id();}private function uid(){return(int)$this->session->userdata('user_id');}
public function index(){$data=array('active_menu'=>'sales_documents','rows'=>$this->db->select('s.*,c.nama')->from('sales_documents s')->join('customers c','c.id=s.customer_id')->where('s.company_id',$this->company())->order_by('s.document_date','DESC')->limit(200)->get()->result(),'permissions'=>array('create'=>is_master_admin_user()||check_permission('invoices','can_create'),'update'=>is_master_admin_user()||check_permission('invoices','can_update'),'approve'=>is_master_admin_user()||check_permission('invoices','can_approve'),'export'=>is_master_admin_user()||check_permission('invoices','can_export')));$this->load->view('partials/header',$data);$this->load->view('sales_documents/index',$data);$this->load->view('partials/footer',$data);}
public function detail($id){try{$d=$this->db->select('s.*,c.nama customer_name,c.alamat')->from('sales_documents s')->join('customers c','c.id=s.customer_id')->where(array('s.id'=>(int)$id,'s.company_id'=>$this->company()))->get()->row();if(!$d)throw new BusinessException('Dokumen penjualan tidak ditemukan.');$lines=$this->db->select('l.*,i.kode_detail AS kode_barang,i.nama_barang')->from('sales_document_lines l')->join('items i','i.id=l.item_id','left')->where(array('l.sales_document_id'=>$d->id,'l.company_id'=>$this->company()))->order_by('l.id')->get()->result();return json_response(true,'Detail ditemukan.',array('document'=>$d,'lines'=>$lines));}catch(Throwable$e){return business_exception_response($e);}}
public function save(){return$this->action(function(){$type=$this->input->post('document_type');if(!in_array($type,array('quotation','sales_order'),true))throw new BusinessException('Jenis dokumen tidak valid.');$customer=$this->db->get_where('customers',array('id'=>(int)$this->input->post('customer_id'),'company_id'=>$this->company()))->row();if(!$customer)throw new BusinessException('Customer tidak valid.');$rows=json_decode((string)$this->input->post('rows'),true);if(!is_array($rows)||!$rows)throw new BusinessException('Tambahkan minimal satu barang.');$date=$this->input->post('document_date');$subtotal=0;$taxTotal=0;$clean=array();foreach($rows as$n=>$row){$item=$this->db->get_where('items',array('id'=>(int)($row['item_id']??0),'company_id'=>$this->company(),'status'=>'active'))->row();$qty=round((float)str_replace(',','.',(string)($row['qty']??0)),4);$price=$this->money($row['unit_price']??0);$discount=max(0,$this->money($row['discount_amount']??0));$tax=max(0,(float)str_replace(',','.',(string)($row['tax_rate']??0)));if(!$item||$qty<=0||$discount>$qty*$price)throw new BusinessException('Detail barang baris '.($n+1).' tidak valid.');$base=round($qty*$price-$discount,2);$taxAmount=round($base*$tax/100,2);$subtotal+=$base;$taxTotal+=$taxAmount;$clean[]=array('item'=>$item,'qty'=>$qty,'price'=>$price,'discount'=>$discount,'tax'=>$tax,'tax_amount'=>$taxAmount,'total'=>$base+$taxAmount,'specification'=>trim((string)($row['specification']??'')));}$no=$this->numberingservice->next($type,$date);$this->db->insert('sales_documents',array('company_id'=>$this->company(),'document_type'=>$type,'document_no'=>$no,'customer_id'=>$customer->id,'document_date'=>$date,'valid_until'=>$this->input->post('valid_until')?:null,'status'=>'draft','subtotal'=>$subtotal,'tax_total'=>$taxTotal,'total'=>$subtotal+$taxTotal,'notes'=>$this->input->post('notes',true),'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s')));$id=(int)$this->db->insert_id();foreach($clean as$x)$this->db->insert('sales_document_lines',array('company_id'=>$this->company(),'sales_document_id'=>$id,'item_id'=>$x['item']->id,'account_id'=>null,'description'=>$x['item']->nama_barang,'specification'=>$x['specification'],'unit'=>null,'qty'=>$x['qty'],'unit_price'=>$x['price'],'discount_amount'=>$x['discount'],'tax_rate'=>$x['tax'],'tax_amount'=>$x['tax_amount'],'line_total'=>$x['total']));$this->auditservice->record('sales','sales_document',$id,'create',null,array('document_no'=>$no,'line_count'=>count($clean),'total'=>$subtotal+$taxTotal),$this->uid());return$id;},'Dokumen penjualan berhasil disimpan.');}
public function workflow($id,$state){return$this->action(function()use($id,$state){$d=$this->db->query('SELECT * FROM sales_documents WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d)throw new BusinessException('Dokumen tidak ditemukan.');$allowed=array('draft'=>array('sent','cancelled'),'sent'=>array('accepted','rejected'),'accepted'=>array('cancelled'));if(!in_array($state,$allowed[$d->status]??array(),true))throw new BusinessException('Perubahan status dokumen tidak diizinkan.');$this->db->where('id',$d->id)->update('sales_documents',array('status'=>$state,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('sales','sales_document',$d->id,'workflow',$d,array('status'=>$state),$this->uid());return$d->id;},'Status dokumen berhasil diperbarui.');}
public function convert($id){return$this->action(function()use($id){$d=$this->db->query('SELECT * FROM sales_documents WHERE id=? AND company_id=? FOR UPDATE',array((int)$id,$this->company()))->row();if(!$d||$d->document_type!=='quotation'||$d->status!=='accepted')throw new BusinessException('Hanya Penawaran Diterima yang dapat dijadikan Sales Order.');$existing=$this->db->get_where('sales_documents',array('source_quotation_id'=>$d->id,'company_id'=>$this->company()))->row();if($existing)return$existing->id;$no=$this->numberingservice->next('sales_order',date('Y-m-d'));$copy=(array)$d;unset($copy['id']);$copy['document_type']='sales_order';$copy['document_no']=$no;$copy['document_date']=date('Y-m-d');$copy['status']='draft';$copy['source_quotation_id']=$d->id;$copy['parent_document_id']=$d->id;$copy['created_by']=$this->uid();$copy['created_at']=date('Y-m-d H:i:s');$copy['updated_at']=null;$copy['updated_by']=null;$this->db->insert('sales_documents',$copy);$new=(int)$this->db->insert_id();foreach($this->db->get_where('sales_document_lines',array('sales_document_id'=>$d->id,'company_id'=>$this->company()))->result_array()as$l){unset($l['id']);$l['sales_document_id']=$new;$this->db->insert('sales_document_lines',$l);}$this->db->where('id',$d->id)->update('sales_documents',array('status'=>'converted','updated_at'=>date('Y-m-d H:i:s'),'updated_by'=>$this->uid()));return$new;},'Penawaran berhasil dikonversi menjadi Sales Order.');}
private function money($v){$v=trim(str_ireplace(array('Rp',' '),'',(string)$v));if(strpos($v,',')!==false)$v=str_replace(',','.',str_replace('.','',$v));elseif(preg_match('/^\d{1,3}(\.\d{3})+$/',$v))$v=str_replace('.','',$v);return round((float)$v,2);}
private function action($fn,$message){if(strtoupper($this->input->method())!=='POST')return json_response(false,'Metode tidak diizinkan.',array(),405);try{return json_response(true,$message,array('result'=>$this->transactionservice->run($fn)));}catch(Throwable$e){return business_exception_response($e);}}
}
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Scalabilitycheck extends CI_Controller
{
public function index($iterations=10){if(!$this->input->is_cli_request())show_404();$iterations=min(100,max(1,(int)$iterations));$company=$this->db->order_by('id')->get('companies')->row();$queries=array('dashboard_summary'=>array("SELECT SUM(d.debit),SUM(d.kredit) FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed')",array($company->id)),'open_receivables'=>array("SELECT i.id FROM invoices i WHERE i.company_id=? AND i.workflow_status='posted' AND i.sisa_piutang>0 ORDER BY i.jatuh_tempo LIMIT 50",array($company->id)),'general_ledger'=>array("SELECT d.id FROM journal_details d JOIN journals j ON j.id=d.journal_id WHERE j.company_id=? AND j.status IN('posted','reversed') ORDER BY j.tanggal DESC,j.id DESC LIMIT 100",array($company->id)));$timings=array();foreach($queries as$name=>$q){$samples=array();for($i=0;$i<$iterations;$i++){$s=microtime(true);$this->db->query($q[0],$q[1])->result();$samples[]=(microtime(true)-$s)*1000;}sort($samples);$timings[$name]=array('avg_ms'=>round(array_sum($samples)/count($samples),3),'p95_ms'=>round($samples[(int)floor((count($samples)-1)*.95)],3),'target_ms'=>2000,'pass'=>max($samples)<2000);}$volumes=array();foreach(array('journals','journal_details','invoices','supplier_invoices','inventory_ledger','activity_logs','app_jobs','app_dead_letters')as$t)$volumes[$t]=$this->db->table_exists($t)?(int)$this->db->count_all($t):null;$result=array('iterations'=>$iterations,'volumes'=>$volumes,'queries'=>$timings,'all_pass'=>!in_array(false,array_column($timings,'pass'),true));echo json_encode($result,JSON_PRETTY_PRINT).PHP_EOL;if(!$result['all_pass'])exit(1);}
}
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Scheduler extends CI_Controller {
class Scheduler extends MY_Controller {
public function __construct()
{
@@ -641,4 +641,4 @@ class Scheduler extends CI_Controller {
]
);
}
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Setting extends CI_Controller {
class Setting extends MY_Controller {
public function __construct()
{
+2 -2
View File
@@ -1,7 +1,7 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Shifts extends CI_Controller {
class Shifts extends MY_Controller {
public function __construct()
{
@@ -144,4 +144,4 @@ class Shifts extends CI_Controller {
'message'=> 'Shift berhasil dihapus'
]);
}
}
}
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Stage1011check extends CI_Controller
{
public function index(){if(!$this->input->is_cli_request())show_404();$this->load->database();$checks=array('migration'=>(int)$this->db->get('migrations')->row()->version,'employees'=>(int)$this->db->count_all('k_employees'),'employment_history'=>(int)$this->db->count_all('employee_employment_history'),'history_missing'=>(int)$this->db->query('SELECT COUNT(*) n FROM k_employees e LEFT JOIN employee_employment_history h ON h.employee_id=e.id WHERE h.id IS NULL')->row()->n,'duplicate_raw_attendance'=>(int)$this->db->query('SELECT COUNT(*) n FROM(SELECT device_id,payload_hash FROM attendance_raw_logs GROUP BY device_id,payload_hash HAVING COUNT(*)>1)x')->row()->n,'locked_attendance'=>(int)$this->db->where('status','locked')->count_all_results('attendance_period_locks'),'payroll_components'=>(int)$this->db->count_all('payroll_components'),'final_payroll_without_hash'=>(int)$this->db->query("SELECT COUNT(*) n FROM k_payroll_periods WHERE workflow_status IN('final','posted','paid') AND calculation_hash IS NULL")->row()->n,'posted_payroll_without_journal'=>(int)$this->db->query("SELECT COUNT(*) n FROM k_payroll_periods WHERE workflow_status IN('posted','paid') AND journal_id IS NULL")->row()->n,'payroll_register_mismatch'=>(int)$this->db->query("SELECT COUNT(*) n FROM k_payrolls WHERE ABS(total_salary-(gross_salary-total_deductions))>.01")->row()->n,'tax_codes'=>(int)$this->db->count_all('tax_codes'),'tax_transactions'=>(int)$this->db->count_all('tax_transactions'),'duplicate_tax_keys'=>(int)$this->db->query('SELECT COUNT(*) n FROM(SELECT idempotency_key FROM tax_transactions GROUP BY idempotency_key HAVING COUNT(*)>1)x')->row()->n,'tax_without_source'=>(int)$this->db->query("SELECT COUNT(*) n FROM tax_transactions WHERE source_id IS NULL OR source_type='' ")->row()->n);echo json_encode($checks,JSON_PRETTY_PRINT).PHP_EOL;}
}
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Stage1214check extends CI_Controller
{
public function index(){if(!$this->input->is_cli_request())show_404();$this->load->database();$critical=array('accounts','journals','journal_details','customers','invoices','suppliers','supplier_invoices','items','assets','cash_transactions','k_employees','k_payrolls','tax_transactions');$null=0;$detail=array();foreach($critical as$t){$n=(int)$this->db->where('company_id IS NULL',null,false)->count_all_results($t);$detail[$t]=$n;$null+=$n;}$q=array('migration'=>(int)$this->db->get('migrations')->row()->version,'companies'=>(int)$this->db->count_all('companies'),'user_without_company'=>(int)$this->db->query('SELECT COUNT(*)n FROM users u LEFT JOIN user_companies x ON x.user_id=u.id WHERE x.id IS NULL')->row()->n,'critical_rows_without_company'=>$null,'critical_detail'=>$detail,'journal_lines_without_company'=>(int)$this->db->where('company_id IS NULL',null,false)->count_all_results('journal_details'),'journal_lines_with_dimension'=>(int)$this->db->group_start()->where('branch_id IS NOT NULL',null,false)->or_where('department_id IS NOT NULL',null,false)->or_where('project_id IS NOT NULL',null,false)->or_where('cost_center_id IS NOT NULL',null,false)->group_end()->count_all_results('journal_details'),'unbalanced_by_company'=>(int)$this->db->query('SELECT COUNT(*)n FROM(SELECT j.company_id,j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id GROUP BY j.company_id,j.id HAVING ABS(SUM(d.debit-d.kredit))>.01)x')->row()->n,'budgets'=>(int)$this->db->count_all('budgets'),'currencies'=>(int)$this->db->count_all('currencies'),'duplicate_rates'=>(int)$this->db->query('SELECT COUNT(*)n FROM(SELECT company_id,rate_date,from_currency,to_currency FROM exchange_rates GROUP BY company_id,rate_date,from_currency,to_currency HAVING COUNT(*)>1)x')->row()->n);echo json_encode($q,JSON_PRETTY_PRINT).PHP_EOL;}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Stage15check extends CI_Controller
{
public function index(){if(!$this->input->is_cli_request())show_404();$root=FCPATH;$header=file_get_contents(APPPATH.'views/partials/header.php');$footer=file_get_contents(APPPATH.'views/partials/footer.php');$nav=file_get_contents(APPPATH.'libraries/NavigationService.php');$sw=file_get_contents($root.'service-worker.js');$manifest=json_decode(file_get_contents($root.'manifest.webmanifest'),true);$iconsOk=true;foreach($manifest['icons']as$i){$path=$root.$i['src'];$size=is_file($path)?getimagesize($path):false;$expected=explode('x',$i['sizes']);if(!$size||$size[0]!=(int)$expected[0]||$size[1]!=(int)$expected[1])$iconsOk=false;}$checks=array('single_menu_partial'=>substr_count($header,"partials/navigation_items")===2,'desktop_mobile_same_source'=>substr_count($header,"compact('menu_items','active_menu')")===2,'dead_umum_removed'=>strpos($nav,"'url'=>'umum'")===false,'menu_search'=>strpos($header,'menu-filter')!==false,'quick_actions'=>strpos($header,'quickActions')!==false,'breadcrumb'=>strpos($header,'app-breadcrumb')!==false,'approval_badge'=>strpos($nav,'approvalBadge')!==false,'local_bootstrap'=>is_file($root.'assets/vendor/bootstrap.min.css')&&is_file($root.'assets/vendor/bootstrap.bundle.min.js'),'local_jquery'=>is_file($root.'assets/vendor/jquery.min.js'),'responsive_css'=>is_file($root.'assets/css/app-responsive.min.css'),'double_submit_guard'=>strpos(file_get_contents($root.'assets/js/app-ui.min.js'),"dataset.submitting")!==false,'offline_post_block'=>strpos(file_get_contents($root.'assets/js/app-ui.min.js'),"navigator.onLine")!==false,'static_only_cache'=>strpos($sw,"/assets/")!==false&&strpos($sw,"method!=='GET'")!==false,'pwa_icons_exact'=>$iconsOk,'manifest_standalone'=>isset($manifest['display'])&&$manifest['display']==='standalone','duplicate_login_swal'=>substr_count(file_get_contents(APPPATH.'views/auth/login.php'),'sweetalert2')<=1);$failed=array_keys(array_filter($checks,function($x){return!$x;}));echo json_encode(array('checks'=>$checks,'failed'=>$failed,'viewport_artifacts'=>array('360x800','390x844','768x1024','1366x768')),JSON_PRETTY_PRINT).PHP_EOL;if($failed)exit(1);}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Stage16check extends CI_Controller
{
public function index(){if(!$this->input->is_cli_request())show_404();$this->load->library(array('PerformanceCacheService','JobQueueService'));$required=array('ci_sessions','app_cache','dashboard_summaries','app_jobs','app_dead_letters','database_health_metrics','activity_logs_archive');$missing=array_values(array_filter($required,function($t){return!$this->db->table_exists($t);}));$cache=$this->performancecacheservice->remember('stage16:check',60,function(){return array('ok'=>true,'at'=>date('c'));});$company=$this->db->order_by('id')->get('companies')->row();$payload=array('report_code'=>'journal_register','format'=>'csv','filters'=>array('from'=>date('Y-01-01'),'to'=>date('Y-m-d')),'requested_by'=>1);$first=$this->jobqueueservice->enqueue('report_export',$payload,'stage16:test:report','reports',5,3,$company->id);$second=$this->jobqueueservice->enqueue('report_export',$payload,'stage16:test:report','reports',5,3,$company->id);$job=$this->db->get_where('app_jobs',array('id'=>$first))->row();$result=array('missing_tables'=>$missing,'cache_ok'=>!empty($cache['ok']),'queue_idempotent'=>$first===$second,'job_id'=>$first,'job_status'=>$job->status,'db_session_driver'=>$this->config->item('sess_driver'),'status'=>!$missing&&!empty($cache['ok'])&&$first===$second?'ok':'failed');echo json_encode($result,JSON_PRETTY_PRINT).PHP_EOL;if($result['status']!=='ok')exit(1);}
}
+103
View File
@@ -0,0 +1,103 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
/** QA integrasi read-only untuk invariant accounting dan kontrol aplikasi. */
class Stage17check extends CI_Controller
{
private $checks = array();
public function __construct()
{
parent::__construct();
if (!$this->input->is_cli_request()) show_error('QA check hanya tersedia melalui CLI.', 403);
}
public function index()
{
$this->zero('journal_balance', "SELECT COUNT(*) n FROM (SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.status IN('posted','reversed') GROUP BY j.id HAVING ABS(SUM(d.debit)-SUM(d.kredit))>.009) x", 'Jurnal posted/reversed harus balance.');
$this->zero('posted_journal_has_lines', "SELECT COUNT(*) n FROM journals j LEFT JOIN journal_details d ON d.journal_id=j.id WHERE j.status IN('posted','reversed') GROUP BY j.id HAVING COUNT(d.id)<2", 'Setiap jurnal posted minimal memiliki dua baris.', true);
$this->zero('invoice_journal_trace', "SELECT COUNT(*) n FROM invoices i LEFT JOIN journals j ON j.id=i.journal_id WHERE i.workflow_status IN('posted','reversed') AND i.posted_by IS NOT NULL AND j.id IS NULL AND EXISTS(SELECT 1 FROM invoice_details d WHERE d.invoice_id=i.id AND d.line_type<>'savings')", 'Invoice penjualan posted harus memiliki jurnal sumber; invoice Tabungan baru dijurnal saat pembayaran.');
$this->warning('legacy_invoice_without_journal', "SELECT COUNT(*) n FROM invoices WHERE workflow_status IN('posted','reversed') AND posted_by IS NULL AND journal_id IS NULL", 'Invoice legacy tanpa jurnal perlu direkonsiliasi sebelum cut-over production.');
$this->zero('invoice_balance_formula', "SELECT COUNT(*) n FROM invoices WHERE workflow_status IN('posted','reversed') AND ABS(sisa_piutang-GREATEST(total-total_bayar,0))>.01", 'Saldo invoice harus sama dengan total dikurangi pembayaran.');
$this->zero('payment_allocation_limit', "SELECT COUNT(*) n FROM payments p LEFT JOIN (SELECT payment_id,SUM(amount) allocated FROM payment_allocations GROUP BY payment_id)a ON a.payment_id=p.id WHERE COALESCE(a.allocated,0)>p.jumlah+.01", 'Alokasi pembayaran tidak boleh melebihi penerimaan.');
$this->zero('payment_journal_trace', "SELECT COUNT(*) n FROM payments p LEFT JOIN journals j ON j.id=p.journal_id WHERE p.status='posted' AND p.payment_no NOT LIKE 'LEG-PAY-%' AND j.id IS NULL", 'Pembayaran baru posted harus memiliki jurnal.');
$this->zero('purchase_payment_bank_charge_formula', "SELECT COUNT(*) n FROM supplier_payments WHERE ABS(total_cash_out-(amount+bank_charge_amount))>.01 OR bank_charge_amount<0 OR (bank_charge_amount>0 AND bank_charge_account_id IS NULL)", 'Total kas keluar pembelian harus sama dengan pokok pembayaran ditambah admin bank.');
$this->zero('purchase_payment_source_total', "SELECT COUNT(*) n FROM supplier_payments p LEFT JOIN (SELECT supplier_payment_id,SUM(amount) source_total FROM purchase_payment_sources GROUP BY supplier_payment_id) s ON s.supplier_payment_id=p.id WHERE p.status='posted' AND ABS(COALESCE(s.source_total,p.amount)-p.total_cash_out)>.01", 'Total sumber Kas/Bank harus sama dengan total kas keluar pembayaran pembelian.');
$this->zero('purchase_payment_bank_charge_mapping', "SELECT COUNT(*) n FROM (SELECT 1 marker) x WHERE NOT EXISTS(SELECT 1 FROM system_account_mappings m JOIN accounts a ON a.id=m.account_id WHERE m.mapping_key='bank_charge_expense' AND m.is_active=1 AND a.is_active=1 AND a.allow_posting=1 AND a.tipe='expense')", 'Mapping beban administrasi bank harus mengarah ke akun beban aktif.');
$this->zero('purchase_return_draft_does_not_move_stock', "SELECT COUNT(*) n FROM stock_logs sl JOIN purchase_returns pr ON pr.id=sl.ref_id AND sl.ref_type='purchase_return' WHERE pr.status IN('draft','submitted','approved','reserved')", 'Draft, pengajuan, approval, dan reservasi retur tidak boleh mengurangi stok.');
$this->zero('purchase_return_quantity_limit', "SELECT COUNT(*) n FROM (SELECT l.receipt_line_id,SUM(CASE WHEN h.status NOT IN('draft','rejected','cancelled','reversed') THEN l.qty ELSE 0 END) returned_qty,MAX(grl.qty) received_qty FROM purchase_return_lines l JOIN purchase_returns h ON h.id=l.purchase_return_id JOIN goods_receipt_lines grl ON grl.id=l.receipt_line_id GROUP BY l.receipt_line_id HAVING returned_qty>received_qty+.0001)x", 'Total retur tidak boleh melebihi kuantitas penerimaan.');
$this->zero('purchase_return_post_idempotency', "SELECT COUNT(*) n FROM (SELECT purchase_return_line_id FROM purchase_return_shipment_lines GROUP BY purchase_return_line_id HAVING COUNT(*)>1)x", 'Satu baris retur tidak boleh diposting ke pengiriman lebih dari satu kali.');
$this->zero('purchase_return_barcode_reservation', "SELECT COUNT(*) n FROM item_barcodes WHERE return_reserved_qty<0 OR return_reserved_qty>qty_sisa+.0001", 'Reservasi retur barcode tidak boleh negatif atau melebihi saldo barcode.');
$this->zero('supplier_refund_balance_formula', "SELECT COUNT(*) n FROM supplier_refund_claims WHERE ABS(balance-GREATEST(claim_amount-received_amount,0))>.01 OR received_amount>claim_amount+.01", 'Saldo refund harus sama dengan nilai klaim dikurangi dana diterima.');
$this->zero('supplier_refund_receipt_limit', "SELECT COUNT(*) n FROM (SELECT supplier_refund_claim_id,SUM(amount) received,MAX(c.claim_amount) claimed FROM supplier_refund_receipts r JOIN supplier_refund_claims c ON c.id=r.supplier_refund_claim_id WHERE r.status<>'reversed' GROUP BY supplier_refund_claim_id HAVING received>claimed+.01)x", 'Akumulasi penerimaan refund tidak boleh melebihi klaim.');
$this->zero('purchase_adjustment_tenant_isolation', "SELECT COUNT(*) n FROM purchase_return_lines l JOIN purchase_returns h ON h.id=l.purchase_return_id WHERE COALESCE(l.company_id,0)<>COALESCE(h.company_id,0)", 'Header dan detail retur wajib berada pada perusahaan yang sama.');
$this->zero('purchase_adjustment_mapping', "SELECT COUNT(*) n FROM (SELECT 'supplier_refund_receivable' k UNION ALL SELECT 'purchase_return_adjustment' UNION ALL SELECT 'purchase_price_variance') x WHERE NOT EXISTS(SELECT 1 FROM system_account_mappings m JOIN accounts a ON a.id=m.account_id WHERE m.mapping_key=x.k AND m.is_active=1 AND a.is_active=1 AND a.allow_posting=1)", 'Seluruh mapping akun Retur/Refund harus aktif dan dapat diposting.');
$this->zero('purchase_adjustment_orphan_attachment', "SELECT COUNT(*) n FROM transaction_attachments a WHERE (a.entity_type='purchase_return' AND NOT EXISTS(SELECT 1 FROM purchase_returns x WHERE x.id=a.entity_id)) OR (a.entity_type='purchase_replacement' AND NOT EXISTS(SELECT 1 FROM purchase_replacements x WHERE x.id=a.entity_id)) OR (a.entity_type='supplier_refund' AND NOT EXISTS(SELECT 1 FROM supplier_refund_claims x WHERE x.id=a.entity_id)) OR (a.entity_type='supplier_refund_receipt' AND NOT EXISTS(SELECT 1 FROM supplier_refund_receipts x WHERE x.id=a.entity_id)) OR (a.entity_type='supplier_debit_note' AND NOT EXISTS(SELECT 1 FROM supplier_debit_notes x WHERE x.id=a.entity_id))", 'Lampiran penyesuaian pembelian wajib memiliki dokumen sumber.');
$this->warning('legacy_payment_without_journal', "SELECT COUNT(*) n FROM payments WHERE status='posted' AND payment_no LIKE 'LEG-PAY-%' AND journal_id IS NULL", 'Pembayaran legacy tanpa jurnal perlu direkonsiliasi sebelum cut-over production.');
$this->zero('credit_note_journal_trace', "SELECT COUNT(*) n FROM credit_notes c LEFT JOIN journals j ON j.id=c.journal_id WHERE c.status='posted' AND j.id IS NULL", 'Credit note posted harus memiliki jurnal reversal nilai.');
$this->zero('inventory_negative_stock', "SELECT COUNT(*) n FROM (SELECT item_id,warehouse_id,SUM(IF(direction='in',qty,-qty)) qty FROM inventory_ledger GROUP BY item_id,warehouse_id HAVING qty<-.0001)x", 'Stock ledger tidak boleh negatif.');
$this->zero('inventory_duplicate_idempotency', "SELECT COUNT(*) n FROM (SELECT idempotency_key FROM inventory_ledger GROUP BY idempotency_key HAVING COUNT(*)>1)x", 'Mutasi stok tidak boleh diproses dua kali.');
$this->zero('inventory_source_trace', "SELECT COUNT(*) n FROM inventory_ledger WHERE document_type='' OR document_id IS NULL", 'Mutasi stok wajib memiliki dokumen sumber.');
$this->zero('depreciation_duplicate', "SELECT COUNT(*) n FROM (SELECT asset_id,period FROM asset_depreciation_schedule GROUP BY asset_id,period HAVING COUNT(*)>1)x", 'Penyusutan bulanan harus idempotent.');
$this->zero('depreciation_residual_limit', "SELECT COUNT(*) n FROM asset_depreciation_schedule s JOIN assets a ON a.id=s.asset_id WHERE s.closing_book_value<a.nilai_residu-.01 OR s.depreciation_amount<0", 'Penyusutan tidak boleh melewati nilai residu.');
$this->zero('payroll_formula', "SELECT COUNT(*) n FROM k_payrolls WHERE ABS(total_salary-(gross_salary-total_deductions))>.01", 'Payroll register harus konsisten.');
$this->zero('payroll_journal_trace', "SELECT COUNT(*) n FROM k_payroll_periods p LEFT JOIN journals j ON j.id=p.journal_id WHERE p.workflow_status IN('posted','paid') AND j.id IS NULL", 'Payroll posted harus memiliki jurnal.');
$this->zero('closed_period_mutation', "SELECT COUNT(*) n FROM journals j JOIN accounting_periods p ON p.id=j.accounting_period_id WHERE p.status='closed' AND j.updated_at>p.closed_at", 'Tidak boleh ada jurnal diubah setelah periode ditutup.');
$this->zero('reversal_preserves_history', "SELECT COUNT(*) n FROM journals r LEFT JOIN journals o ON o.id=r.reversal_of WHERE r.reversal_of IS NOT NULL AND o.id IS NULL", 'Reversal wajib mempertahankan jurnal asli.');
$this->zero('reversal_opposite_value', "SELECT COUNT(*) n FROM journals r JOIN journals o ON o.id=r.reversal_of JOIN (SELECT journal_id,SUM(debit) d,SUM(kredit) c FROM journal_details GROUP BY journal_id) rd ON rd.journal_id=r.id JOIN (SELECT journal_id,SUM(debit) d,SUM(kredit) c FROM journal_details GROUP BY journal_id) od ON od.journal_id=o.id WHERE ABS(rd.d-od.c)>.01 OR ABS(rd.c-od.d)>.01", 'Nilai reversal harus berkebalikan dengan jurnal asli.');
$this->permissionChecks();
$this->zero('single_master_admin', "SELECT ABS(COUNT(*)-1) n FROM roles WHERE is_super_admin=1 AND nama_role='Admin' AND is_active=1", 'Harus tepat satu role Admin aktif sebagai Master Admin.');
$this->zero('system_role_without_description', "SELECT COUNT(*) n FROM roles WHERE is_system=1 AND (description IS NULL OR TRIM(description)='')", 'Setiap role bawaan wajib memiliki uraian tanggung jawab.');
$this->reportCoverage();
$failed = array_filter($this->checks, function ($row) { return $row['status'] !== 'pass'; });
echo json_encode(array('status' => $failed ? 'failed' : 'ok', 'checked_at' => date('c'), 'checks' => $this->checks), JSON_PRETTY_PRINT) . PHP_EOL;
if ($failed) exit(1);
}
private function zero($name, $sql, $detail, $grouped = false)
{
try {
$query = $this->db->query($grouped ? 'SELECT COUNT(*) n FROM (' . $sql . ') qa' : $sql);
$count = (int) $query->row()->n;
$this->checks[$name] = array('status' => $count === 0 ? 'pass' : 'fail', 'violations' => $count, 'detail' => $detail);
} catch (Throwable $e) {
$this->checks[$name] = array('status' => 'fail', 'violations' => null, 'detail' => $detail . ' SQL: ' . $e->getMessage());
}
}
private function permissionChecks()
{
$invalid = 0; $roles = $this->db->get('roles')->result();
foreach ($roles as $role) {
$permissions = json_decode($role->permissions, true);
if (!is_array($permissions)) { $invalid++; continue; }
foreach ($permissions as $feature => $actions) {
if (!is_string($feature) || !is_array($actions)) $invalid++;
foreach ((array) $actions as $action) if (!in_array($action, array('can_view','can_create','can_update','can_delete','can_approve','can_post','can_export','can_import'), true)) $invalid++;
}
}
$this->checks['role_permission_schema'] = array('status' => $invalid === 0 ? 'pass' : 'fail', 'violations' => $invalid, 'detail' => 'Konfigurasi permission role harus valid dan memakai action yang dikenal.');
}
private function warning($name, $sql, $detail)
{
try { $count = (int) $this->db->query($sql)->row()->n; }
catch (Throwable $e) { $count = null; $detail .= ' SQL: ' . $e->getMessage(); }
$this->checks[$name] = array('status' => 'pass', 'severity' => 'warning', 'violations' => $count, 'detail' => $detail);
}
private function reportCoverage()
{
try {
$access=$this->db->order_by('is_default','DESC')->get('user_companies')->row();if(!$access)throw new RuntimeException('Akses perusahaan untuk QA tidak tersedia.');$this->session->set_userdata(array('user_id'=>$access->user_id,'company_id'=>$access->company_id));
$this->load->library(array('ProfessionalReportService','CompanyContext'));
$filters=array('from'=>'1900-01-01','to'=>date('Y-m-d'),'branch_id'=>0,'department_id'=>0,'project_id'=>0,'cost_center_id'=>0,'profit_center_id'=>0);
$balance=$this->professionalreportservice->run('balance_sheet',$filters);$profit=$this->professionalreportservice->run('profit_loss',$filters);
$company=$this->companycontext->id();
$balanceAccounts=$this->db->where('company_id',$company)->where_in('tipe',array('asset','liability','equity'))->count_all_results('accounts');
$profitAccounts=$this->db->where('company_id',$company)->where_in('tipe',array('revenue','expense'))->count_all_results('accounts');
$ok=count($balance)===$balanceAccounts&&count($profit)===$profitAccounts;
$this->checks['financial_report_all_accounts']=array('status'=>$ok?'pass':'fail','violations'=>$ok?0:abs(count($balance)-$balanceAccounts)+abs(count($profit)-$profitAccounts),'detail'=>'Neraca dan laba rugi wajib memuat seluruh akun termasuk saldo nol.');
} catch(Throwable$e) {$this->checks['financial_report_all_accounts']=array('status'=>'fail','violations'=>null,'detail'=>$e->getMessage());}
}
}
+6
View File
@@ -0,0 +1,6 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class Stage89check extends CI_Controller
{
public function index(){if(!$this->input->is_cli_request())show_404();$this->load->database();$q=array('migration'=>(int)$this->db->get('migrations')->row()->version,'assets'=>(int)$this->db->count_all('assets'),'asset_events'=>(int)$this->db->count_all('asset_events'),'duplicate_depreciation'=>(int)$this->db->query("SELECT COUNT(*) n FROM(SELECT asset_id,period FROM asset_depreciation_schedule GROUP BY asset_id,period HAVING COUNT(*)>1)x")->row()->n,'below_residual'=>(int)$this->db->query("SELECT COUNT(*) n FROM assets WHERE nilai_buku<nilai_residu")->row()->n,'orphan_asset_events'=>(int)$this->db->query("SELECT COUNT(*) n FROM asset_events e LEFT JOIN assets a ON a.id=e.asset_id WHERE a.id IS NULL")->row()->n,'cash_accounts'=>(int)$this->db->count_all('cash_accounts'),'cash_transactions'=>(int)$this->db->count_all('cash_transactions'),'duplicate_cash_keys'=>(int)$this->db->query("SELECT COUNT(*) n FROM(SELECT idempotency_key FROM cash_transactions GROUP BY idempotency_key HAVING COUNT(*)>1)x")->row()->n,'posted_without_journal'=>(int)$this->db->query("SELECT COUNT(*) n FROM cash_transactions WHERE status='posted' AND journal_id IS NULL AND transaction_type<>'opening'")->row()->n,'unbalanced_auto_journals'=>(int)$this->db->query("SELECT COUNT(*) n FROM(SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('asset_depreciation','asset_addition','asset_impairment','asset_revaluation','asset_disposal','asset_lost','asset_damaged','cash_transaction','cash_transfer') GROUP BY j.id HAVING ABS(SUM(d.debit-d.kredit))>.01)x")->row()->n);echo json_encode($q,JSON_PRETTY_PRINT).PHP_EOL;}
}

Some files were not shown because too many files have changed in this diff Show More